diff --git a/.github/actions/ci-guard/action.yml b/.github/actions/ci-guard/action.yml
new file mode 100644
index 000000000..70a6473e9
--- /dev/null
+++ b/.github/actions/ci-guard/action.yml
@@ -0,0 +1,23 @@
+name: Guard
+description: >
+ Loads the current PR title (not the stale event payload) and cancels the workflow when it
+ contains skip-ci, case-insensitive. No-op when the event is not pull_request.
+inputs:
+ github_token:
+ description: Token with actions:write and pull-requests:read
+ required: true
+runs:
+ using: composite
+ steps:
+ - run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" != "pull_request" ]; then
+ exit 0
+ fi
+ PR_TITLE=$(gh pr view ${{ github.event.pull_request.number }} --repo "${{ github.repository }}" --json title --jq .title)
+ if printf '%s' "$PR_TITLE" | grep -Fqi 'skip-ci'; then
+ gh run cancel "${{ github.run_id }}" --repo "${{ github.repository }}"
+ fi
+ shell: bash
+ env:
+ GH_TOKEN: ${{ inputs.github_token }}
diff --git a/.github/workflows/sdk-size-metrics.yml b/.github/workflows/sdk-size-metrics.yml
index cde0c6b7a..63ee74821 100644
--- a/.github/workflows/sdk-size-metrics.yml
+++ b/.github/workflows/sdk-size-metrics.yml
@@ -20,17 +20,22 @@ jobs:
sdk_size:
name: Metrics
runs-on: macos-15
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
env:
GITHUB_TOKEN: '${{ secrets.CI_BOT_GITHUB_TOKEN }}'
GITHUB_PR_NUM: ${{ github.event.pull_request.number }}
steps:
+ - uses: actions/checkout@v3.1.0
+
+ - uses: ./.github/actions/ci-guard
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
- name: Connect Bot
uses: webfactory/ssh-agent@v0.7.0
with:
ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }}
- - uses: actions/checkout@v3.1.0
-
- uses: ./.github/actions/bootstrap
- name: Run General SDK Size Metrics
diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml
index 4d145fbb2..c99e3759f 100644
--- a/.github/workflows/smoke-checks.yml
+++ b/.github/workflows/smoke-checks.yml
@@ -26,9 +26,19 @@ env:
GITHUB_PR_NUM: ${{ github.event.pull_request.number }}
jobs:
+ guard:
+ name: Guard
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4.1.1
+ - uses: ./.github/actions/ci-guard
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
build-test-app-and-frameworks:
name: Build Test App and Frameworks
runs-on: macos-15
+ needs: guard
if: ${{ github.event.inputs.record_snapshots != 'true' }}
steps:
- uses: actions/checkout@v4.1.1
@@ -49,6 +59,7 @@ jobs:
automated-code-review:
name: Automated Code Review
runs-on: macos-14
+ needs: guard
if: ${{ github.event.inputs.record_snapshots != 'true' }}
env:
XCODE_VERSION: "16.1"
@@ -65,6 +76,7 @@ jobs:
build-old-xcode:
name: Build SDKs (Old Xcode)
runs-on: macos-14
+ needs: guard
if: ${{ github.event.inputs.record_snapshots != 'true' }}
env:
XCODE_VERSION: "16.1"
@@ -83,6 +95,7 @@ jobs:
test-ui-debug:
name: Test SwiftUI (Debug)
runs-on: macos-15
+ needs: guard
env:
GITHUB_TOKEN: ${{ secrets.CI_BOT_GITHUB_TOKEN }} # to open a PR
steps:
@@ -124,7 +137,9 @@ jobs:
name: Launch Allure TestOps
runs-on: macos-15
if: ${{ github.event.inputs.record_snapshots != 'true' }}
- needs: build-test-app-and-frameworks
+ needs:
+ - guard
+ - build-test-app-and-frameworks
outputs:
launch_id: ${{ steps.get_launch_id.outputs.launch_id }}
steps:
@@ -144,6 +159,7 @@ jobs:
runs-on: macos-15
if: ${{ github.event.inputs.record_snapshots != 'true' }}
needs:
+ - guard
- allure_testops_launch
- build-test-app-and-frameworks
env:
@@ -198,7 +214,9 @@ jobs:
name: Build Demo App
runs-on: macos-15
if: ${{ github.event.inputs.record_snapshots != 'true' }}
- needs: build-test-app-and-frameworks
+ needs:
+ - guard
+ - build-test-app-and-frameworks
steps:
- uses: actions/checkout@v4.1.1
- uses: actions/download-artifact@v4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe1ec17c7..611cd81b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### π Changed
+# [5.1.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/5.1.0)
+_April 23, 2026_
+
+### π Changed
+- `CDNRequester` is now passed in the constructor of `StreamMediaLoader` instead of `Utils` [#1425](https://github.com/GetStream/stream-chat-swiftui/pull/1425)
+
+### π Fixed
+- Fix voice recording gesture and "hold to record" tip firing while the mic button is hidden [#1433](https://github.com/GetStream/stream-chat-swiftui/pull/1433)
+- Fix swipe-to-reply gesture conflicting with message list scrolling [#1431](https://github.com/GetStream/stream-chat-swiftui/pull/1431)
+- Fix double grey checkmarks not showing for delivered messages in the message list [#1432](https://github.com/GetStream/stream-chat-swiftui/pull/1432)
+- Fix SDK not compiling with Xcode 16 [#1430](https://github.com/GetStream/stream-chat-swiftui/pull/1430)
+- Fix show/hide message translation animation [#1426](https://github.com/GetStream/stream-chat-swiftui/pull/1426)
+- Fix tapping a media attachment in the reactions overlay opening the fullscreen gallery [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
+- Fix empty space around the previewed message in the reactions overlay not dismissing the overlay [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
+- Fix long-pressing a message with attachments occasionally opening the fullscreen gallery [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
+- Fix image attachments briefly showing a loading indicator when reopening a cached image [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
+
# [5.0.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/5.0.0)
_April 16, 2026_
diff --git a/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift b/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift
index 28dc2f965..d4d303019 100644
--- a/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift
+++ b/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift
@@ -2,6 +2,7 @@
// Copyright Β© 2026 Stream.io Inc. All rights reserved.
//
+import Dispatch
import StreamChat
import StreamChatCommonUI
import StreamChatSwiftUI
@@ -12,7 +13,7 @@ import SwiftUI
@Published var searchText: String = "" {
didSet {
- searchUsers(with: searchText)
+ scheduleDebouncedUserSearch()
}
}
@@ -49,11 +50,29 @@ import SwiftUI
private lazy var searchController: ChatUserSearchController = chatClient.userSearchController()
private let lastSeenDateFormatter = DateUtils.timeAgo
+ /// Matches UIKit demo `CreateChatViewController.throttleTime` / `CreateGroupViewController.throttleTime`.
+ private let userSearchDebounceMilliseconds = 1000
+ private var userSearchDebounceWorkItem: DispatchWorkItem?
+ private var userSearchRequestGeneration: UInt64 = 0
+
+ private struct ActiveUserSearch: Equatable {
+ var apiTerm: String?
+ }
+
+ /// Last user-list query that finished successfully (`apiTerm == nil` is βall usersβ).
+ private enum UserListFetchCursor: Equatable {
+ case notYetFetched
+ case fetched(apiTerm: String?)
+ }
+
+ private var activeUserSearch: ActiveUserSearch?
+ private var userListFetchCursor: UserListFetchCursor = .notYetFetched
+
init() {
chatUsers = searchController.userArray
searchController.delegate = self
- // Empty initial search to get all users
- searchUsers(with: nil)
+ // Empty initial search to get all users (immediate β not debounced; same as UIKit `viewDidLoad`)
+ performUserSearch(term: nil)
}
func userTapped(_ user: ChatUser) {
@@ -117,17 +136,63 @@ import SwiftUI
// MARK: - private
- private func searchUsers(with term: String?) {
+ private func scheduleDebouncedUserSearch() {
+ let nextTerm = normalizedUserSearchTerm(searchText)
+ if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, nextTerm) {
+ return
+ }
+ if case let .fetched(prev) = userListFetchCursor,
+ matchesUserSearchTerm(prev, nextTerm),
+ state != .error {
+ return
+ }
+
+ state = .loading
+ userSearchDebounceWorkItem?.cancel()
+ let query = searchText
+ let work = DispatchWorkItem { [weak self] in
+ self?.performUserSearch(term: query.isEmpty ? nil : query)
+ }
+ userSearchDebounceWorkItem = work
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + .milliseconds(userSearchDebounceMilliseconds),
+ execute: work
+ )
+ }
+
+ private func performUserSearch(term: String?) {
+ if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, term) {
+ return
+ }
+ activeUserSearch = ActiveUserSearch(apiTerm: term)
+ userSearchRequestGeneration += 1
+ let generation = userSearchRequestGeneration
state = .loading
searchController.search(term: term) { [weak self] error in
+ guard let self else { return }
+ guard generation == self.userSearchRequestGeneration else { return }
+ self.activeUserSearch = nil
if error != nil {
- self?.state = .error
+ self.state = .error
} else {
- self?.state = .loaded
+ self.userListFetchCursor = .fetched(apiTerm: term)
+ self.state = self.chatUsers.isEmpty ? .noUsers : .loaded
}
}
}
+ private func normalizedUserSearchTerm(_ text: String) -> String? {
+ text.isEmpty ? nil : text
+ }
+
+ private func matchesUserSearchTerm(_ lhs: String?, _ rhs: String?) -> Bool {
+ switch (lhs, rhs) {
+ case (nil, nil): true
+ case let (l?, r?): l == r
+ default: false
+ }
+ }
+
private func makeChannelController() throws {
let selectedUserIds = Set(selectedUsers.map(\.id))
channelController = try chatClient.channelController(
diff --git a/DemoAppSwiftUI/CreateGroupViewModel.swift b/DemoAppSwiftUI/CreateGroupViewModel.swift
index f7578f558..2351f787e 100644
--- a/DemoAppSwiftUI/CreateGroupViewModel.swift
+++ b/DemoAppSwiftUI/CreateGroupViewModel.swift
@@ -2,6 +2,7 @@
// Copyright Β© 2026 Stream.io Inc. All rights reserved.
//
+import Dispatch
import StreamChat
import StreamChatCommonUI
import StreamChatSwiftUI
@@ -14,7 +15,7 @@ import SwiftUI
@Published var searchText = "" {
didSet {
- searchUsers(with: searchText)
+ scheduleDebouncedUserSearch()
}
}
@@ -28,11 +29,28 @@ import SwiftUI
private lazy var searchController: ChatUserSearchController = chatClient.userSearchController()
private let lastSeenDateFormatter = DateUtils.timeAgo
+ /// Matches UIKit demo `CreateGroupViewController.throttleTime`.
+ private let userSearchDebounceMilliseconds = 1000
+ private var userSearchDebounceWorkItem: DispatchWorkItem?
+ private var userSearchRequestGeneration: UInt64 = 0
+
+ private struct ActiveUserSearch: Equatable {
+ var apiTerm: String?
+ }
+
+ private enum UserListFetchCursor: Equatable {
+ case notYetFetched
+ case fetched(apiTerm: String?)
+ }
+
+ private var activeUserSearch: ActiveUserSearch?
+ private var userListFetchCursor: UserListFetchCursor = .notYetFetched
+
init() {
chatUsers = searchController.userArray
searchController.delegate = self
- // Empty initial search to get all users
- searchUsers(with: nil)
+ // Empty initial search to get all users (immediate β not debounced)
+ performUserSearch(term: nil)
}
var canCreateGroup: Bool {
@@ -98,14 +116,60 @@ import SwiftUI
// MARK: - private
- private func searchUsers(with term: String?) {
+ private func scheduleDebouncedUserSearch() {
+ let nextTerm = normalizedUserSearchTerm(searchText)
+ if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, nextTerm) {
+ return
+ }
+ if case let .fetched(prev) = userListFetchCursor,
+ matchesUserSearchTerm(prev, nextTerm),
+ state != .error {
+ return
+ }
+
+ state = .loading
+ userSearchDebounceWorkItem?.cancel()
+ let query = searchText
+ let work = DispatchWorkItem { [weak self] in
+ self?.performUserSearch(term: query.isEmpty ? nil : query)
+ }
+ userSearchDebounceWorkItem = work
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + .milliseconds(userSearchDebounceMilliseconds),
+ execute: work
+ )
+ }
+
+ private func performUserSearch(term: String?) {
+ if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, term) {
+ return
+ }
+ activeUserSearch = ActiveUserSearch(apiTerm: term)
+ userSearchRequestGeneration += 1
+ let generation = userSearchRequestGeneration
state = .loading
searchController.search(term: term) { [weak self] error in
+ guard let self else { return }
+ guard generation == self.userSearchRequestGeneration else { return }
+ self.activeUserSearch = nil
if error != nil {
- self?.state = .error
+ self.state = .error
} else {
- self?.state = .loaded
+ self.userListFetchCursor = .fetched(apiTerm: term)
+ self.state = self.chatUsers.isEmpty ? .noUsers : .loaded
}
}
}
+
+ private func normalizedUserSearchTerm(_ text: String) -> String? {
+ text.isEmpty ? nil : text
+ }
+
+ private func matchesUserSearchTerm(_ lhs: String?, _ rhs: String?) -> Bool {
+ switch (lhs, rhs) {
+ case (nil, nil): true
+ case let (l?, r?): l == r
+ default: false
+ }
+ }
}
diff --git a/Package.swift b/Package.swift
index 702f90f3f..b9014e64e 100644
--- a/Package.swift
+++ b/Package.swift
@@ -16,7 +16,7 @@ let package = Package(
)
],
dependencies: [
- .package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "5.0.0")
+ .package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "5.1.0")
],
targets: [
.target(
diff --git a/README.md b/README.md
index 8c00d814d..15ee1d037 100644
--- a/README.md
+++ b/README.md
@@ -5,14 +5,14 @@
-
+
## SwiftUI StreamChat SDK
-We have redesigned the SwiftUI SDK with a new modern look, a unified design system, cleaner API and many other improvements. The new major v5 version will be in beta until beginning of April, 2026. To learn more, check our [v5 docs](https://getstream.io/chat/docs/sdk/ios/v5/).
+We have redesigned the SwiftUI SDK with a new modern look, a unified design system, cleaner API and many other improvements. The new major v5 version is available since April, 2026. To learn more, check our [v5 docs](https://getstream.io/chat/docs/sdk/ios/).
-If you want to use a stable version, please check our [v4 releases](https://github.com/GetStream/stream-chat-swiftui/releases).
+If you are still using our previous version, the v4 releases are available [here](https://github.com/GetStream/stream-chat-swiftui/releases).
The SwiftUI SDK is built on top of the [StreamChat](https://getstream.io/chat/docs/ios-swift/?language=swift) framework and it's a SwiftUI alternative to the [StreamChatUI](https://getstream.io/chat/docs/sdk/ios/) SDK. It's built completely in SwiftUI, using declarative patterns, that will be familiar to developers working with SwiftUI. The SDK includes an extensive set of performant and customizable UI components which allow you to get started quickly with little to no plumbing required.
diff --git a/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerInputState.swift b/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerInputState.swift
index d8733fd3d..266f0d935 100644
--- a/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerInputState.swift
+++ b/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerInputState.swift
@@ -11,3 +11,33 @@ public enum MessageComposerInputState {
case editing(hasContent: Bool)
case allowAudioRecording
}
+
+extension MessageComposerInputState {
+ /// Resolves the composer input state from the inputs that affect which trailing
+ /// control the composer should render.
+ ///
+ /// Shared between `MessageComposerViewModel.composerInputState` and the view's
+ /// local computed state so both stay in sync (including the
+ /// `VoiceRecordingGestureOverlay` visibility) without duplicating the decision
+ /// tree or introducing a breaking API change.
+ init(
+ cooldownDuration: Int,
+ isEditingMessage: Bool,
+ isInstantCommandActive: Bool,
+ isVoiceRecordingEnabled: Bool,
+ hasContent: Bool,
+ canSendMessage: Bool
+ ) {
+ if cooldownDuration > 0 {
+ self = .slowMode(cooldownDuration: cooldownDuration)
+ } else if isEditingMessage {
+ self = .editing(hasContent: hasContent)
+ } else if isInstantCommandActive {
+ self = .creating(hasContent: hasContent, hasCommand: true)
+ } else if isVoiceRecordingEnabled && !hasContent && canSendMessage {
+ self = .allowAudioRecording
+ } else {
+ self = .creating(hasContent: hasContent, hasCommand: false)
+ }
+ }
+}
diff --git a/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerView.swift b/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerView.swift
index 94ce4be95..03729be5a 100644
--- a/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerView.swift
+++ b/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerView.swift
@@ -662,23 +662,14 @@ public struct ComposerInputView: View, KeyboardReadable {
}
private var composerInputState: MessageComposerInputState {
- if isInCooldown {
- return .slowMode(cooldownDuration: cooldownDuration)
- }
-
- if editedMessage.wrappedValue != nil {
- return .editing(hasContent: hasContent)
- }
-
- if command?.displayInfo?.isInstant == true {
- return .creating(hasContent: hasContent, hasCommand: true)
- }
-
- if utils.composerConfig.isVoiceRecordingEnabled && !hasContent {
- return .allowAudioRecording
- }
-
- return .creating(hasContent: hasContent, hasCommand: false)
+ MessageComposerInputState(
+ cooldownDuration: cooldownDuration,
+ isEditingMessage: editedMessage.wrappedValue != nil,
+ isInstantCommandActive: command?.displayInfo?.isInstant == true,
+ isVoiceRecordingEnabled: utils.composerConfig.isVoiceRecordingEnabled,
+ hasContent: hasContent,
+ canSendMessage: canSendMessage
+ )
}
private var isInCooldown: Bool {
diff --git a/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerViewModel.swift b/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerViewModel.swift
index b8af1f7a0..623503792 100644
--- a/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerViewModel.swift
+++ b/Sources/StreamChatSwiftUI/ChatComposer/MessageComposerViewModel.swift
@@ -486,13 +486,36 @@ import SwiftUI
!addedVoiceRecordings.isEmpty
}
+ /// The current state of the composer's input view.
+ ///
+ /// Determines which trailing control is rendered (send button, confirm-edit button,
+ /// mic button, or slow-mode indicator) and is the single source of truth shared
+ /// between `ComposerInputView`, `TrailingInputComposerView`, and
+ /// `shouldShowRecordingGestureOverlay`.
+ public var composerInputState: MessageComposerInputState {
+ MessageComposerInputState(
+ cooldownDuration: cooldownDuration,
+ isEditingMessage: editedMessage?.wrappedValue != nil,
+ isInstantCommandActive: composerCommand?.displayInfo?.isInstant == true,
+ isVoiceRecordingEnabled: utils.composerConfig.isVoiceRecordingEnabled,
+ hasContent: hasContent,
+ canSendMessage: canSendMessage
+ )
+ }
+
/// Whether the voice recording gesture overlay should be active.
///
- /// The overlay must only be shown when the mic button is visible (no content and voice
- /// recording enabled) or while a recording is in progress.
+ /// Mirrors the visibility of the mic button: the overlay is only active while a
+ /// recording is in progress, or while the composer state is `.allowAudioRecording`
+ /// (voice recording enabled, no slow-mode cooldown, no edited message, no active
+ /// instant command, no composer content, and the channel allows sending messages).
+ /// Otherwise the invisible gesture would keep capturing taps and surface the
+ /// "hold to record" tip without a visible mic.
public var shouldShowRecordingGestureOverlay: Bool {
- guard utils.composerConfig.isVoiceRecordingEnabled else { return false }
- return (recordingState == .initial && !hasContent) || recordingState.isRecording
+ if recordingState.isRecording { return true }
+ guard recordingState == .initial else { return false }
+ if case .allowAudioRecording = composerInputState, canSendMessage { return true }
+ return false
}
public var sendInChannelShown: Bool {
@@ -1202,8 +1225,7 @@ final class FileAddedAsset {
}
utils.mediaLoader.loadImage(
- url: imageAttachment.imageURL,
- options: ImageLoadOptions(resize: nil, cdnRequester: utils.cdnRequester)
+ url: imageAttachment.imageURL
) { result in
if let image = (try? result.get())?.image {
let imageAsset = AddedAsset(
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentPreview.swift b/Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentPreview.swift
index 1497376ae..b48ddeaf1 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentPreview.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentPreview.swift
@@ -14,11 +14,9 @@ public struct FileAttachmentPreview: View {
@Injected(\.images) private var images
@Injected(\.utils) private var utils
- private var cdnRequester: CDNRequester { utils.cdnRequester }
-
let attachment: ChatMessageFileAttachment
- @State private var adjustedUrl: URL?
+ @State private var fileRequest: URLRequest?
@State private var isLoading = false
@State private var webViewTitle: String?
@State private var error: Error?
@@ -45,9 +43,9 @@ public struct FileAttachmentPreview: View {
.font(fonts.body)
.padding()
} else {
- if let adjustedUrl {
+ if let fileRequest {
WebView(
- url: adjustedUrl,
+ request: fileRequest,
isLoading: $isLoading,
title: $webViewTitle,
error: $error
@@ -60,14 +58,12 @@ public struct FileAttachmentPreview: View {
}
}
.onAppear {
- cdnRequester.fileRequest(for: url, options: .init()) { result in
- Task { @MainActor in
- switch result {
- case let .success(cdnRequest):
- adjustedUrl = cdnRequest.url
- case let .failure(error):
- self.error = error
- }
+ utils.mediaLoader.loadFileRequest(for: url) { result in
+ switch result {
+ case let .success(result):
+ fileRequest = result.urlRequest
+ case let .failure(error):
+ self.error = error
}
}
}
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentView.swift
index 4e34d8957..3c1f7414f 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentView.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentView.swift
@@ -279,11 +279,11 @@ struct DownloadShareAttachmentView: View
let messageId = attachment.id.messageId
let cid = attachment.id.cid
let messageController = chatClient.messageController(cid: cid, messageId: messageId)
- let cdnRequester = InjectedValues[\.utils].cdnRequester
- cdnRequester.fileRequest(for: attachment.remoteURL, options: .init()) { result in
+ let mediaLoader = InjectedValues[\.utils].mediaLoader
+ mediaLoader.loadFileRequest(for: attachment.remoteURL) { result in
switch result {
- case let .success(cdnRequest):
- messageController.downloadAttachment(attachment, remoteURL: cdnRequest.url) { result in
+ case let .success(fileRequest):
+ messageController.downloadAttachment(attachment, request: fileRequest.urlRequest) { result in
if case let .failure(error) = result {
log.error("Error downloading attachment: \(error.localizedDescription)")
} else {
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/Gallery/MediaViewer.swift b/Sources/StreamChatSwiftUI/ChatMessageList/Gallery/MediaViewer.swift
index 0ab2cf5ac..572e4fe46 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/Gallery/MediaViewer.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/Gallery/MediaViewer.swift
@@ -343,8 +343,7 @@ struct StreamVideoPlayer: View {
return
}
utils.mediaLoader.loadVideoAsset(
- at: url,
- options: VideoLoadOptions(cdnRequester: utils.cdnRequester)
+ at: url
) { result in
guard isVisible else { return }
switch result {
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/GiphyAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/GiphyAttachmentView.swift
index 5916a9f1f..220fb5c67 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/GiphyAttachmentView.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/GiphyAttachmentView.swift
@@ -132,7 +132,7 @@ struct LazyGiphyView: View {
) { phase in
switch phase {
case .success(let result):
- if result.isAnimated, let gifData = result.animatedImageData {
+ if let gifData = result.animatedImageData {
AnimatedGifView(gifData: gifData)
} else {
Image(uiImage: result.image)
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/MediaAttachment.swift b/Sources/StreamChatSwiftUI/ChatMessageList/MediaAttachment.swift
index 70d2852fe..709f3677d 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/MediaAttachment.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/MediaAttachment.swift
@@ -40,12 +40,11 @@ public final class MediaAttachment: Identifiable, Equatable, Sendable {
completion: @escaping @MainActor (Result) -> Void
) {
let utils = InjectedValues[\.utils]
- let cdnRequester = utils.cdnRequester
if type == .image {
let imageResize: ImageResize? = resize ? ImageResize(preferredSize) : nil
utils.mediaLoader.loadImage(
url: url,
- options: ImageLoadOptions(resize: imageResize, cdnRequester: cdnRequester)
+ options: ImageLoadOptions(resize: imageResize)
) { result in
completion(result.map(\.image))
}
@@ -56,8 +55,7 @@ public final class MediaAttachment: Identifiable, Equatable, Sendable {
return
}
utils.mediaLoader.loadVideoPreview(
- with: videoAttachment,
- options: VideoLoadOptions(cdnRequester: cdnRequester)
+ with: videoAttachment
) { result in
completion(result.map(\.image))
}
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/MessageContainerView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/MessageContainerView.swift
index cc9369bb3..f54928502 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/MessageContainerView.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/MessageContainerView.swift
@@ -145,6 +145,10 @@ struct MessageContainerView: View {
messageViewModel.failureIndicatorShown ? SendFailureIndicator() : nil
)
.frame(maxWidth: contentWidth, alignment: messageViewModel.isRightAligned ? .trailing : .leading)
+ .highPriorityGesture(
+ TapGesture().onEnded { /* Swallow taps on the bubble so attachments don't open and the overlay doesn't dismiss. */ },
+ including: shownAsPreview ? .all : .none
+ )
}
@ViewBuilder
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift
index 469930fe7..7c58688f0 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/MessageItemView.swift
@@ -107,18 +107,11 @@ public struct MessageItemView: View {
}
)
.contentShape(Rectangle())
- .allowsHitTesting(!shownAsPreview || (messageViewModel.usesScrollView))
- .onTapGesture(count: 2) {
- if messageViewModel.isDoubleTapOverlayEnabled {
- handleGestureForMessage(showsMessageActions: true)
- }
- }
- .simultaneousGesture(
- LongPressGesture(minimumDuration: 0.3)
- .onEnded { _ in
- handleGestureForMessage(showsMessageActions: true)
- }
- )
+ .modifier(MessageActionsGestureModifier(
+ shownAsPreview: shownAsPreview,
+ isDoubleTapEnabled: messageViewModel.isDoubleTapOverlayEnabled,
+ onActionsTriggered: { handleGestureForMessage(showsMessageActions: true) }
+ ))
.modifier(SwipeToReplyModifier(
message: message,
channel: channel,
@@ -196,6 +189,49 @@ public struct MessageItemView: View {
}
}
+// MARK: - Message Actions Gesture
+
+/// Attaches the double-tap and long-press gestures that open the message actions
+/// overlay on a `MessageItemView`.
+///
+/// When the message is rendered as a preview inside the reactions overlay
+/// (`shownAsPreview == true`), both gestures are intentionally skipped. Keeping
+/// them would force SwiftUI to wait for double-tap / long-press disambiguation
+/// before delivering a single tap to the overlay's dismiss handler, introducing
+/// a noticeable delay when the user taps the empty space around the message
+/// bubble to dismiss.
+struct MessageActionsGestureModifier: ViewModifier {
+ /// Whether the message is rendered inside the reactions overlay.
+ /// When `true`, no gestures are attached.
+ let shownAsPreview: Bool
+ /// Whether double-tap should trigger the message actions overlay.
+ let isDoubleTapEnabled: Bool
+ /// Invoked when either the double-tap or long-press is recognized.
+ let onActionsTriggered: () -> Void
+
+ private let longPressMinimumDuration: Double = 0.3
+
+ @ViewBuilder
+ func body(content: Content) -> some View {
+ if shownAsPreview {
+ content
+ } else {
+ content
+ .onTapGesture(count: 2) {
+ if isDoubleTapEnabled {
+ onActionsTriggered()
+ }
+ }
+ .highPriorityGesture(
+ LongPressGesture(minimumDuration: longPressMinimumDuration)
+ .onEnded { _ in
+ onActionsTriggered()
+ }
+ )
+ }
+ }
+}
+
// MARK: - Swipe to Reply
/// Areas that should not trigger swipe-to-reply (e.g. waveform sliders).
@@ -221,7 +257,6 @@ struct SwipeToReplyModifier: ViewModifier {
@State private var offsetX: CGFloat
@State private var swipeExcludedFrames: [CGRect] = []
- @GestureState private var offset: CGSize = .zero
private let replyThreshold: CGFloat = 60
@@ -249,13 +284,14 @@ struct SwipeToReplyModifier: ViewModifier {
content
.coordinateSpace(name: "swipeToReply")
.offset(x: min(offsetX, maximumHorizontalSwipeDisplacement))
- .gesture(
+ .simultaneousGesture(
DragGesture(
minimumDistance: minimumSwipeDistance,
coordinateSpace: .named("swipeToReply")
)
- .updating($offset) { (value, gestureState, _) in
- guard isSwipeToQuoteReplyPossible else {
+ .onChanged { value in
+ guard isSwipeToQuoteReplyPossible,
+ channel.config.quotesEnabled else {
return
}
@@ -263,29 +299,12 @@ struct SwipeToReplyModifier: ViewModifier {
return
}
- let diff = CGSize(
- width: value.location.x - value.startLocation.x,
- height: value.location.y - value.startLocation.y
- )
-
- if diff == .zero {
- gestureState = .zero
- } else {
- gestureState = value.translation
- }
+ dragChanged(to: value.translation.width)
}
- )
- .onChange(of: offset, perform: { _ in
- if !channel.config.quotesEnabled {
- return
- }
-
- if offset == .zero {
+ .onEnded { _ in
setOffsetX(value: 0)
- } else {
- dragChanged(to: offset.width)
}
- })
+ )
.onPreferenceChange(SwipeToReplyExcludedFrameKey.self) { frames in
swipeExcludedFrames = frames
}
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/MessageListConfig.swift b/Sources/StreamChatSwiftUI/ChatMessageList/MessageListConfig.swift
index 580c2c55d..b04fdede6 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/MessageListConfig.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/MessageListConfig.swift
@@ -203,7 +203,7 @@ public final class MessageDisplayOptions {
overlayDateLabelSize: CGFloat = 40,
lastInGroupHeaderSize: CGFloat = 0,
newMessagesSeparatorSize: CGFloat = 50,
- minimumSwipeGestureDistance: CGFloat = 20,
+ minimumSwipeGestureDistance: CGFloat = 30,
currentUserMessageTransition: AnyTransition = .identity,
otherUserMessageTransition: AnyTransition = .identity,
shouldAnimateReactions: Bool = true,
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/MessageMediaAttachmentContentView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/MessageMediaAttachmentContentView.swift
index 87b82736a..1e5ad1953 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/MessageMediaAttachmentContentView.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/MessageMediaAttachmentContentView.swift
@@ -7,10 +7,17 @@ import SwiftUI
/// A view that renders a single media attachment (image or video) thumbnail.
///
-/// Uses ``MediaAttachment/generateThumbnail(resize:preferredSize:completion:)``
-/// to load and display thumbnails.
-/// Shows a gradient placeholder while the thumbnail is loading.
-/// For video attachments, a play icon is overlaid on the thumbnail.
+/// Images are loaded through ``StreamAsyncImage``, which consults Nuke's
+/// in-memory cache synchronously and avoids a loading flash when the
+/// thumbnail was rendered earlier (e.g. when the message is shown again
+/// inside the reactions overlay). Video thumbnails go through
+/// ``MediaAttachment/generateThumbnail(resize:preferredSize:completion:)``
+/// because they can originate from ``AVAssetImageGenerator`` and are not
+/// pure image URLs.
+///
+/// Shows a gradient placeholder and a spinner while the thumbnail is
+/// loading. For video attachments, a play icon is overlaid on the
+/// successfully loaded thumbnail.
public struct MessageMediaAttachmentContentView: View {
@Injected(\.colors) private var colors
@@ -32,8 +39,12 @@ public struct MessageMediaAttachmentContentView: View {
/// Called when the user taps the retry badge on an upload-failed attachment.
let onUploadRetry: (() -> Void)?
- @State private var image: UIImage?
- @State private var error: Error?
+ /// Video preview image, loaded once on appear.
+ @State private var videoPreview: UIImage?
+ /// Error from the most recent video preview load.
+ @State private var videoPreviewError: Error?
+ /// Bumped to force ``StreamAsyncImage`` to retry a failed image load.
+ @State private var imageRetryToken = 0
public init(
factory: Factory,
@@ -65,35 +76,7 @@ public struct MessageMediaAttachmentContentView: View {
public var body: some View {
ZStack {
- if let image {
- Image(uiImage: image)
- .resizable()
- .scaledToFill()
- .frame(width: width, height: height)
- .clipped()
- } else if error != nil {
- placeholderBackground
- } else {
- placeholderGradient
- }
-
- if image == nil && error == nil && !isUploading {
- LoadingSpinnerView(size: LoadingSpinnerSize.medium)
- .allowsHitTesting(false)
- }
-
- if error != nil && source.uploadingState == nil {
- retryOverlay { loadThumbnail() }
- }
-
- if let uploadingState = source.uploadingState {
- uploadingOverlay(for: uploadingState)
- }
-
- if source.type == .video && width > 64 && source.uploadingState == nil && image != nil && error == nil {
- VideoPlayIndicatorView(size: VideoPlayIndicatorSize.medium)
- .allowsHitTesting(false)
- }
+ thumbnail
}
.frame(width: width, height: height)
.clipShape(
@@ -102,30 +85,139 @@ public struct MessageMediaAttachmentContentView: View {
corners: corners ?? [.topLeft, .topRight, .bottomLeft, .bottomRight]
)
)
+ .accessibilityIdentifier("MessageMediaAttachmentContentView")
+ }
+
+ // MARK: - Thumbnail
+
+ @ViewBuilder
+ private var thumbnail: some View {
+ if source.type == .image {
+ imageThumbnail
+ } else if source.type == .video {
+ videoThumbnail
+ } else {
+ placeholderBackground
+ }
+ }
+
+ @ViewBuilder
+ private var imageThumbnail: some View {
+ StreamAsyncImage(
+ url: source.url,
+ resize: ImageResize(CGSize(width: width, height: height))
+ ) { phase in
+ ZStack {
+ phaseBackground(for: phase)
+ imageOverlays(for: phase)
+ }
+ }
+ .id(imageRetryToken)
+ }
+
+ @ViewBuilder
+ private var videoThumbnail: some View {
+ ZStack {
+ videoBackground
+ videoOverlays
+ }
.onAppear {
- guard image == nil else { return }
- loadThumbnail()
+ guard videoPreview == nil else { return }
+ loadVideoThumbnail()
+ }
+ }
+
+ // MARK: - Phase Rendering (Image)
+
+ @ViewBuilder
+ private func phaseBackground(for phase: StreamAsyncImagePhase) -> some View {
+ switch phase {
+ case let .success(result):
+ Image(uiImage: result.image)
+ .resizable()
+ .scaledToFill()
+ .frame(width: width, height: height)
+ .clipped()
+ case .empty, .error:
+ placeholderBackground
+ case .loading:
+ placeholderGradient
+ }
+ }
+
+ @ViewBuilder
+ private func imageOverlays(for phase: StreamAsyncImagePhase) -> some View {
+ if case .loading = phase, !isUploading {
+ LoadingSpinnerView(size: LoadingSpinnerSize.medium)
+ .allowsHitTesting(false)
+ }
+
+ if case .error = phase, source.uploadingState == nil {
+ retryOverlay { imageRetryToken &+= 1 }
+ }
+
+ if let uploadingState = source.uploadingState {
+ uploadingOverlay(for: uploadingState)
+ }
+ }
+
+ // MARK: - Video Rendering
+
+ @ViewBuilder
+ private var videoBackground: some View {
+ if let videoPreview {
+ Image(uiImage: videoPreview)
+ .resizable()
+ .scaledToFill()
+ .frame(width: width, height: height)
+ .clipped()
+ } else if videoPreviewError != nil {
+ placeholderBackground
+ } else {
+ placeholderGradient
}
- .accessibilityIdentifier("MessageMediaAttachmentContentView")
}
- // MARK: - Private
+ @ViewBuilder
+ private var videoOverlays: some View {
+ if videoPreview == nil, videoPreviewError == nil, !isUploading {
+ LoadingSpinnerView(size: LoadingSpinnerSize.medium)
+ .allowsHitTesting(false)
+ }
+
+ if videoPreviewError != nil, source.uploadingState == nil {
+ retryOverlay { loadVideoThumbnail() }
+ }
- private func loadThumbnail() {
- error = nil
+ if let uploadingState = source.uploadingState {
+ uploadingOverlay(for: uploadingState)
+ }
+
+ if width > 64, source.uploadingState == nil, videoPreview != nil, videoPreviewError == nil {
+ VideoPlayIndicatorView(size: VideoPlayIndicatorSize.medium)
+ .allowsHitTesting(false)
+ }
+ }
+
+ // MARK: - Video Loading
+
+ private func loadVideoThumbnail() {
+ videoPreviewError = nil
source.generateThumbnail(
resize: true,
preferredSize: CGSize(width: width, height: height)
) { result in
switch result {
- case .success(let loaded):
- self.image = loaded
- case .failure(let failure):
- self.error = failure
+ case let .success(loaded):
+ self.videoPreview = loaded
+ case let .failure(failure):
+ self.videoPreviewError = failure
}
}
}
+ // MARK: - Shared Overlays
+
@ViewBuilder
private func uploadingOverlay(for uploadingState: AttachmentUploadingState) -> some View {
switch uploadingState.state {
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/MessageTopView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/MessageTopView.swift
index c5abaa3bb..a18e6b9c0 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/MessageTopView.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/MessageTopView.swift
@@ -77,14 +77,18 @@ struct MessageTopView: View {
title: messageViewModel.originalTextShown ? nil : L10n.Message.Annotation.translated,
buttonTitle: messageViewModel.originalTextShown ? L10n.Message.showTranslation : L10n.Message.showOriginal,
buttonAction: {
- if messageViewModel.originalTextShown {
- messageViewModel.hideOriginalText()
- } else {
- messageViewModel.showOriginalText()
+ withAnimation(.easeInOut(duration: 0.25)) {
+ if messageViewModel.originalTextShown {
+ messageViewModel.hideOriginalText()
+ } else {
+ messageViewModel.showOriginalText()
+ }
}
},
usesInvertedStyle: usesInvertedStyle
)
+ .id(messageViewModel.originalTextShown)
+ .transition(.opacity)
} else {
MessageAnnotationView(
icon: images.annotationTranslation,
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/Reactions/ReactionsOverlayView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/Reactions/ReactionsOverlayView.swift
index 56165921c..41158a5ef 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/Reactions/ReactionsOverlayView.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/Reactions/ReactionsOverlayView.swift
@@ -83,7 +83,12 @@ public struct ReactionsOverlayView: View {
GeometryReader { reader in
let height = reader.frame(in: .local).height
- Color.clear.preference(key: HeightPreferenceKey.self, value: height)
+ Color.clear
+ .preference(key: HeightPreferenceKey.self, value: height)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ dismissReactionsOverlay { /* No additional handling. */ }
+ }
VStack(alignment: isRightAligned ? .trailing : .leading, spacing: tokens.spacingXs) {
reactionsPickerView(reader: reader)
@@ -107,6 +112,9 @@ public struct ReactionsOverlayView: View {
.frame(maxHeight: messageDisplayInfo.frame.height)
.scaleEffect(popIn || willPopOut ? 1 : 0.95)
.animation(willPopOut ? .easeInOut : popInAnimation, value: popIn)
+ .onTapGesture {
+ dismissReactionsOverlay { /* No additional handling. */ }
+ }
messageActionsView(reader: reader)
}
.frame(width: overlayContentWidth, alignment: isRightAligned ? .trailing : .leading)
diff --git a/Sources/StreamChatSwiftUI/ChatMessageList/WebView.swift b/Sources/StreamChatSwiftUI/ChatMessageList/WebView.swift
index 39195dbb0..cf29416af 100644
--- a/Sources/StreamChatSwiftUI/ChatMessageList/WebView.swift
+++ b/Sources/StreamChatSwiftUI/ChatMessageList/WebView.swift
@@ -7,7 +7,7 @@ import WebKit
/// SwiftUI web view wrapper for `WKWebView`.
struct WebView: UIViewRepresentable {
- var url: URL
+ var request: URLRequest
@Binding var isLoading: Bool
@Binding var title: String?
@Binding var error: Error?
@@ -26,7 +26,7 @@ struct WebView: UIViewRepresentable {
webView.navigationDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
webView.scrollView.isScrollEnabled = true
- webView.load(URLRequest(url: url))
+ webView.load(request)
return webView.withAccessibilityIdentifier(identifier: "WKWebView")
}
diff --git a/Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift b/Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift
index 74b690811..9dd7df4b2 100644
--- a/Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift
+++ b/Sources/StreamChatSwiftUI/CommonViews/NukeImageLoader.swift
@@ -2,12 +2,14 @@
// Copyright Β© 2026 Stream.io Inc. All rights reserved.
//
-import StreamChat
+import StreamChatCommonUI
import UIKit
-/// Internal helper that bridges the vendored Nuke pipeline with CDN requester
-/// transformations. Isolates all Nuke-specific code so views remain agnostic
-/// of the underlying image loading library.
+/// Internal helper for synchronous Nuke memory-cache lookups.
+///
+/// ``StreamAsyncImage`` uses this to compute an instant `initialPhase`
+/// when a previously loaded image is still in memory. All actual image
+/// loading goes through ``MediaLoader``.
enum NukeImageLoader {
// MARK: - Synchronous Cache Lookup
@@ -22,6 +24,10 @@ enum NukeImageLoader {
/// Returns `nil` when the image has never been loaded (no stored key)
/// or when Nuke has evicted it from memory.
static func cachedResult(url: URL, resize: ImageResize?) -> StreamAsyncImageResult? {
+ if let override = testSyncLookup?(url, resize) {
+ return override
+ }
+
let key = inputKey(url: url, resize: resize) as NSString
guard let storedKey = cachingKeyMap.object(forKey: key)?.value else { return nil }
@@ -33,86 +39,34 @@ enum NukeImageLoader {
guard let container = ImagePipeline.shared.cache[request] else { return nil }
return StreamAsyncImageResult(
image: container.image,
- isAnimated: container.type == .gif,
- animatedImageData: container.data
+ animatedImageData: container.type == .gif ? container.data : nil
)
}
- // MARK: - Async Loading
+ // MARK: - Test Hook
- /// Loads an image from the given URL, applying CDN transformations and
- /// optional resize processing.
+ /// Test-only hook that lets tests resolve image URLs synchronously.
///
- /// Checks Nuke's memory cache (using the ``CDNRequest/cachingKey``) after
- /// CDN transformation. If the image is already cached, it returns
- /// immediately without a network request. Otherwise, calls `onCacheMiss`
- /// (so callers can show a loading state) and fetches from the network.
+ /// Snapshot tests run entirely synchronously and cannot drive the async
+ /// ``MediaLoader`` pipeline before the view is captured. Installing a
+ /// resolver here makes ``StreamAsyncImage``'s `initialPhase` return a
+ /// `.success` immediately, so mock images render in the first layout
+ /// pass. In production this is always `nil`.
+ nonisolated(unsafe) static var testSyncLookup: ((URL, ImageResize?) -> StreamAsyncImageResult?)?
+
+ /// Stores a caching key for a given URL and resize combination.
///
- /// Uses Nuke's async `ImageTask.response` which propagates Swift
- /// concurrency cancellation to the underlying download automatically.
- @MainActor
- static func loadImage(
- url: URL,
- resize: ImageResize?,
- cdnRequester: CDNRequester,
- onCacheMiss: @MainActor () -> Void = {}
- ) async throws -> StreamAsyncImageResult {
- let cdnResize = resize.map {
- CDNImageResize(width: $0.width, height: $0.height, resizeMode: $0.mode.value, crop: $0.mode.cropValue)
- }
- let cdnRequest = try await cdnRequester.imageRequest(for: url, options: .init(resize: cdnResize))
-
- if let cachingKey = cdnRequest.cachingKey {
- let key = inputKey(url: url, resize: resize) as NSString
- cachingKeyMap.setObject(StringBox(cachingKey), forKey: key)
- }
-
- let processors = makeProcessors(resize: resize)
- let userInfo = cdnRequest.cachingKey.map { [ImageRequest.UserInfoKey.imageIdKey: $0 as Any] }
-
- let cacheRequest = ImageRequest(
- url: cdnRequest.url,
- processors: processors,
- userInfo: userInfo
- )
-
- if let container = ImagePipeline.shared.cache[cacheRequest] {
- return StreamAsyncImageResult(
- image: container.image,
- isAnimated: container.type == .gif,
- animatedImageData: container.data
- )
- }
-
- onCacheMiss()
-
- var urlRequest = URLRequest(url: cdnRequest.url)
- if let headers = cdnRequest.headers {
- for (key, value) in headers {
- urlRequest.setValue(value, forHTTPHeaderField: key)
- }
- }
-
- let networkRequest = ImageRequest(
- urlRequest: urlRequest,
- processors: processors,
- userInfo: userInfo
- )
-
- let task = ImagePipeline.shared.imageTask(with: networkRequest)
- let response = try await task.response
- return StreamAsyncImageResult(
- image: response.image,
- isAnimated: response.container.type == .gif,
- animatedImageData: response.container.data
- )
+ /// Called by ``StreamAsyncImage`` after a successful load through
+ /// ``MediaLoader/loadImage(url:options:completion:)`` so that
+ /// ``cachedResult(url:resize:)`` can find the image in Nuke's
+ /// memory cache on subsequent lookups.
+ static func storeCachingKey(_ cachingKey: String, url: URL, resize: ImageResize?) {
+ let key = inputKey(url: url, resize: resize) as NSString
+ cachingKeyMap.setObject(StringBox(cachingKey), forKey: key)
}
// MARK: - Private
- /// Maps `(url + resize)` β `cachingKey` so ``cachedResult(url:resize:)``
- /// can query Nuke's memory cache using the correct `imageIdKey`.
- /// Populated on the first successful CDN transform for each pair.
private nonisolated(unsafe) static let cachingKeyMap = NSCache()
private static func inputKey(url: URL, resize: ImageResize?) -> String {
@@ -121,7 +75,7 @@ enum NukeImageLoader {
return "\(urlPart)-\(resize.width)x\(resize.height)-\(resize.mode.value)"
}
- private static func makeProcessors(resize: ImageResize?) -> [any ImageProcessing] {
+ static func makeProcessors(resize: ImageResize?) -> [any ImageProcessing] {
guard let resize else { return [] }
let size = CGSize(width: resize.width, height: resize.height)
guard size != .zero else { return [] }
diff --git a/Sources/StreamChatSwiftUI/CommonViews/StreamAsyncImage.swift b/Sources/StreamChatSwiftUI/CommonViews/StreamAsyncImage.swift
index 6d77e7854..1725fa278 100644
--- a/Sources/StreamChatSwiftUI/CommonViews/StreamAsyncImage.swift
+++ b/Sources/StreamChatSwiftUI/CommonViews/StreamAsyncImage.swift
@@ -105,14 +105,25 @@ private struct StreamAsyncImageBody: View {
return
}
+ if case .success = initialPhase {
+ phase = initialPhase
+ return
+ }
+
+ phase = .loading
+
do {
- let result = try await NukeImageLoader.loadImage(
+ let loaded = try await utils.mediaLoader.loadImage(
url: url,
- resize: resize,
- cdnRequester: utils.cdnRequester,
- onCacheMiss: { phase = .loading }
+ options: ImageLoadOptions(resize: resize)
)
- phase = .success(result)
+ if let cachingKey = loaded.cachingKey {
+ NukeImageLoader.storeCachingKey(cachingKey, url: url, resize: resize)
+ }
+ phase = .success(StreamAsyncImageResult(
+ image: loaded.image,
+ animatedImageData: loaded.animatedImageData
+ ))
} catch {
if !(error is CancellationError) {
phase = .error(error)
@@ -158,8 +169,6 @@ public enum StreamAsyncImagePhase {
public struct StreamAsyncImageResult {
/// The loaded image.
public let image: UIImage
- /// Whether the image is an animated format (e.g. GIF).
- public let isAnimated: Bool
/// The raw image data for animated rendering. `nil` for static images.
public let animatedImageData: Data?
}
diff --git a/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift b/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift
index f8dbc0d26..9141f4f4b 100644
--- a/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift
+++ b/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift
@@ -7,5 +7,5 @@ import Foundation
enum SystemEnvironment {
/// A Stream Chat version.
- public static let version: String = "5.0.0"
+ public static let version: String = "5.1.0"
}
diff --git a/Sources/StreamChatSwiftUI/Info.plist b/Sources/StreamChatSwiftUI/Info.plist
index c8d921104..f8c65e615 100644
--- a/Sources/StreamChatSwiftUI/Info.plist
+++ b/Sources/StreamChatSwiftUI/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 5.0.0
+ 5.1.0
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
NSPhotoLibraryUsageDescription
diff --git a/Sources/StreamChatSwiftUI/Utils.swift b/Sources/StreamChatSwiftUI/Utils.swift
index f10bbf89d..2140d77c9 100644
--- a/Sources/StreamChatSwiftUI/Utils.swift
+++ b/Sources/StreamChatSwiftUI/Utils.swift
@@ -18,7 +18,7 @@ import StreamChatCommonUI
public var messageTimestampFormatter: MessageTimestampFormatter
public var galleryHeaderViewDateFormatter: GalleryHeaderViewDateFormatter
public var messageDateSeparatorFormatter: MessageDateSeparatorFormatter
- public var cdnRequester: CDNRequester
+ /// The object responsible for loading images, video previews, and resolving file URLs.
public var mediaLoader: MediaLoader
public var channelNameFormatter: ChannelNameFormatter
public var avPlayerProvider: AVPlayerProvider
@@ -82,8 +82,7 @@ import StreamChatCommonUI
messageTimestampFormatter: MessageTimestampFormatter = ChannelListMessageTimestampFormatter(),
galleryHeaderViewDateFormatter: GalleryHeaderViewDateFormatter = DefaultGalleryHeaderViewDateFormatter(),
messageDateSeparatorFormatter: MessageDateSeparatorFormatter = DefaultMessageDateSeparatorFormatter(),
- cdnRequester: CDNRequester = StreamCDNRequester(),
- mediaLoader: MediaLoader? = nil,
+ mediaLoader: MediaLoader = StreamMediaLoader(downloader: StreamImageDownloader()),
avPlayerProvider: AVPlayerProvider = DefaultAVPlayerProvider(),
messageTypeResolver: MessageTypeResolving = MessageTypeResolver(),
messageActionResolver: MessageActionsResolving = MessageActionsResolver(),
@@ -110,8 +109,7 @@ import StreamChatCommonUI
self.messageTimestampFormatter = messageTimestampFormatter
self.galleryHeaderViewDateFormatter = galleryHeaderViewDateFormatter
self.messageDateSeparatorFormatter = messageDateSeparatorFormatter
- self.cdnRequester = cdnRequester
- self.mediaLoader = mediaLoader ?? StreamMediaLoader(downloader: StreamImageDownloader())
+ self.mediaLoader = mediaLoader
self.channelNameFormatter = channelNameFormatter
self.avPlayerProvider = avPlayerProvider
self.chatUserNamer = chatUserNamer
diff --git a/Sources/StreamChatSwiftUI/Utils/StreamImageDownloader.swift b/Sources/StreamChatSwiftUI/Utils/StreamImageDownloader.swift
index 120263752..bbea7222d 100644
--- a/Sources/StreamChatSwiftUI/Utils/StreamImageDownloader.swift
+++ b/Sources/StreamChatSwiftUI/Utils/StreamImageDownloader.swift
@@ -36,7 +36,10 @@ public final class StreamImageDownloader: ImageDownloading, Sendable {
Task { @MainActor in
switch result {
case let .success(imageResponse):
- completion(.success(DownloadedImage(image: imageResponse.image)))
+ completion(.success(DownloadedImage(
+ image: imageResponse.image,
+ animatedImageData: imageResponse.container.type == .gif ? imageResponse.container.data : nil
+ )))
case let .failure(error):
completion(.failure(error))
}
diff --git a/Sources/StreamChatSwiftUI/ViewFactory/DefaultViewFactory.swift b/Sources/StreamChatSwiftUI/ViewFactory/DefaultViewFactory.swift
index 7f20463b6..8c86e49b2 100644
--- a/Sources/StreamChatSwiftUI/ViewFactory/DefaultViewFactory.swift
+++ b/Sources/StreamChatSwiftUI/ViewFactory/DefaultViewFactory.swift
@@ -898,8 +898,10 @@ extension ViewFactory {
currentUserId: chatClient.currentUserId,
message: options.message
)
+ let showDelivered = options.message.deliveryStatus(for: options.channel) == .delivered
return MessageReadIndicatorView(
readUsers: readUsers,
+ showDelivered: showDelivered,
localState: options.message.localState,
usesInvertedStyle: options.usesInvertedStyle
)
diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj
index a1de6b45d..37d067b99 100644
--- a/StreamChatSwiftUI.xcodeproj/project.pbxproj
+++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj
@@ -1522,8 +1522,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/GetStream/stream-chat-swift.git";
requirement = {
+ minimumVersion = 5.1.0;
kind = upToNextMajorVersion;
- minimumVersion = 5.0.0;
};
};
E3A1C01A282BAC66002D1E26 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
diff --git a/StreamChatSwiftUITests/Infrastructure/Mocks/MediaLoader_Mock.swift b/StreamChatSwiftUITests/Infrastructure/Mocks/MediaLoader_Mock.swift
index 7ae564169..0ffab413b 100644
--- a/StreamChatSwiftUITests/Infrastructure/Mocks/MediaLoader_Mock.swift
+++ b/StreamChatSwiftUITests/Infrastructure/Mocks/MediaLoader_Mock.swift
@@ -30,12 +30,12 @@ class MediaLoader_Mock: MediaLoader, @unchecked Sendable {
options: ImageLoadOptions,
completion: @escaping @MainActor (Result) -> Void
) {
- loadImageCalled = true
- loadImageCallCount += 1
- loadedURLs.append(url)
- loadImageOptions.append(options)
- let image = imageForURL(url)
StreamConcurrency.onMain {
+ loadImageCalled = true
+ loadImageCallCount += 1
+ loadedURLs.append(url)
+ loadImageOptions.append(options)
+ let image = imageForURL(url)
completion(.success(MediaLoaderImage(image: image)))
}
}
@@ -45,8 +45,8 @@ class MediaLoader_Mock: MediaLoader, @unchecked Sendable {
options: VideoLoadOptions,
completion: @escaping @MainActor (Result) -> Void
) {
- loadVideoAssetOptions.append(options)
StreamConcurrency.onMain {
+ loadVideoAssetOptions.append(options)
completion(.success(MediaLoaderVideoAsset(asset: AVURLAsset(url: url))))
}
}
@@ -56,9 +56,9 @@ class MediaLoader_Mock: MediaLoader, @unchecked Sendable {
options: VideoLoadOptions,
completion: @escaping @MainActor (Result) -> Void
) {
- loadVideoPreviewWithAttachmentCalled = true
- loadVideoPreviewOptions.append(options)
StreamConcurrency.onMain {
+ loadVideoPreviewWithAttachmentCalled = true
+ loadVideoPreviewOptions.append(options)
completion(.success(MediaLoaderVideoPreview(image: Self.defaultLoadedImage)))
}
}
@@ -68,14 +68,27 @@ class MediaLoader_Mock: MediaLoader, @unchecked Sendable {
options: VideoLoadOptions,
completion: @escaping @MainActor (Result) -> Void
) {
- loadVideoPreviewAtURLCalled = true
- loadVideoPreviewOptions.append(options)
StreamConcurrency.onMain {
+ loadVideoPreviewAtURLCalled = true
+ loadVideoPreviewOptions.append(options)
completion(.success(MediaLoaderVideoPreview(image: Self.defaultLoadedImage)))
}
}
- private func imageForURL(_ url: URL?) -> UIImage {
+ func loadFileRequest(
+ for url: URL,
+ options: DownloadFileRequestOptions,
+ completion: @escaping @MainActor (Result) -> Void
+ ) {
+ StreamConcurrency.onMain {
+ completion(.success(MediaLoaderFileRequest(urlRequest: URLRequest(url: url))))
+ }
+ }
+
+ /// Synchronous URL-to-image mapping used both by the async `loadImage`
+ /// path and by the snapshot-test sync hook installed in
+ /// `StreamChatTestCase`.
+ func imageForURL(_ url: URL?) -> UIImage {
guard let url else { return Self.defaultLoadedImage }
let urlString = url.absoluteString
if urlString.contains("chewbacca") {
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/LazyLoadingImage_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/LazyLoadingImage_Tests.swift
index 7efe32c15..5a18e6aa3 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/LazyLoadingImage_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/LazyLoadingImage_Tests.swift
@@ -133,13 +133,12 @@ import XCTest
XCTAssertEqual(imageLoader?.loadedURLs.first, .localYodaImage)
}
- // MARK: - CDN Requester
+ // MARK: - MediaLoader Integration
- func test_mediaAttachment_generateThumbnail_usesInjectedCDNRequester() {
+ func test_mediaAttachment_generateThumbnail_usesInjectedMediaLoader() {
// Given
- let customRequester = CDNRequester_Mock()
let mediaLoader = MediaLoader_Mock()
- let utils = Utils(cdnRequester: customRequester, mediaLoader: mediaLoader)
+ let utils = Utils(mediaLoader: mediaLoader)
streamChat = StreamChat(chatClient: chatClient, utils: utils)
let attachment = MediaAttachment(url: .localYodaImage, type: .image)
@@ -154,15 +153,14 @@ import XCTest
wait(for: [expectation], timeout: 2.0)
// Then
- XCTAssertEqual(mediaLoader.loadImageOptions.count, 1)
- XCTAssert(mediaLoader.loadImageOptions.first?.cdnRequester is CDNRequester_Mock)
+ XCTAssertEqual(mediaLoader.loadImageCallCount, 1)
+ XCTAssertNotNil(mediaLoader.loadImageOptions.first?.resize)
}
- func test_mediaAttachment_videoPreview_usesInjectedCDNRequester() {
+ func test_mediaAttachment_videoPreview_usesInjectedMediaLoader() {
// Given
- let customRequester = CDNRequester_Mock()
let mediaLoader = MediaLoader_Mock()
- let utils = Utils(cdnRequester: customRequester, mediaLoader: mediaLoader)
+ let utils = Utils(mediaLoader: mediaLoader)
streamChat = StreamChat(chatClient: chatClient, utils: utils)
let videoAttachment = ChatMessageVideoAttachment(
id: .init(cid: .init(type: .messaging, id: "test"), messageId: "msg", index: 0),
@@ -193,8 +191,7 @@ import XCTest
wait(for: [expectation], timeout: 2.0)
// Then
- XCTAssertEqual(mediaLoader.loadVideoPreviewOptions.count, 1)
- XCTAssert(mediaLoader.loadVideoPreviewOptions.first?.cdnRequester is CDNRequester_Mock)
+ XCTAssertTrue(mediaLoader.loadVideoPreviewWithAttachmentCalled)
}
// MARK: - MediaAttachment Equatable
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift
index 233fcadf1..5b224c6c0 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerViewModel_Tests.swift
@@ -1413,6 +1413,117 @@ import XCTest
XCTAssertFalse(viewModel.shouldShowRecordingGestureOverlay)
}
+ func test_shouldShowRecordingGestureOverlay_whenInstantCommandActive_returnsFalse() {
+ // Given
+ let viewModel = makeComposerViewModel()
+ viewModel.recordingState = .initial
+
+ // When
+ viewModel.composerCommand = makeGiphyCommand()
+
+ // Then
+ XCTAssertFalse(
+ viewModel.shouldShowRecordingGestureOverlay,
+ "The overlay must stay hidden while an instant command is active, since the mic button is replaced by the send button."
+ )
+ }
+
+ func test_shouldShowRecordingGestureOverlay_whenInCooldown_returnsFalse() {
+ // Given
+ let viewModel = makeComposerViewModel()
+ viewModel.recordingState = .initial
+
+ // When
+ viewModel.cooldownDuration = 15
+
+ // Then
+ XCTAssertFalse(
+ viewModel.shouldShowRecordingGestureOverlay,
+ "The overlay must stay hidden during slow-mode cooldown, since the mic button is replaced by the cooldown indicator."
+ )
+ }
+
+ func test_shouldShowRecordingGestureOverlay_whenEditingMessage_returnsFalse() {
+ // Given
+ var editedMessage: ChatMessage? = ChatMessage.mock(
+ id: .unique,
+ cid: .unique,
+ text: "Edited",
+ author: .mock(id: .unique)
+ )
+ let editedBinding = Binding(
+ get: { editedMessage },
+ set: { editedMessage = $0 }
+ )
+ let viewModel = MessageComposerViewModel(
+ channelController: makeChannelController(),
+ messageController: nil,
+ editedMessage: editedBinding
+ )
+ viewModel.recordingState = .initial
+
+ // Then
+ XCTAssertFalse(
+ viewModel.shouldShowRecordingGestureOverlay,
+ "The overlay must stay hidden while editing a message, since the mic button is replaced by the confirm-edit button."
+ )
+ }
+
+ func test_shouldShowRecordingGestureOverlay_whenCannotSendMessage_returnsFalse() {
+ // Given
+ let channelController = makeChannelController()
+ channelController.channel_mock = .mockDMChannel(
+ ownCapabilities: [.uploadFile, .readEvents]
+ )
+ let viewModel = MessageComposerViewModel(
+ channelController: channelController,
+ messageController: nil
+ )
+ viewModel.recordingState = .initial
+
+ // Then
+ XCTAssertFalse(
+ viewModel.canSendMessage,
+ "Precondition: the mock channel should not grant the send-message capability."
+ )
+ XCTAssertFalse(
+ viewModel.shouldShowRecordingGestureOverlay,
+ "The overlay must stay hidden in frozen/no-send channels, even when voice recording is enabled and the composer is empty."
+ )
+ }
+
+ func test_composerInputState_whenCannotSendMessage_returnsCreatingInsteadOfAllowAudioRecording() {
+ // Given
+ let channelController = makeChannelController()
+ channelController.channel_mock = .mockDMChannel(
+ ownCapabilities: [.uploadFile, .readEvents]
+ )
+ let viewModel = MessageComposerViewModel(
+ channelController: channelController,
+ messageController: nil
+ )
+
+ // Then
+ if case .allowAudioRecording = viewModel.composerInputState {
+ XCTFail("composerInputState must not surface .allowAudioRecording when the channel does not allow sending messages.")
+ }
+ }
+
+ func test_shouldShowRecordingGestureOverlay_whenInstantCommandActiveButRecording_returnsTrue() {
+ // Given
+ let viewModel = makeComposerViewModel()
+ viewModel.composerCommand = makeGiphyCommand()
+
+ // When
+ viewModel.recordingState = .recording
+
+ // Then
+ XCTAssertTrue(
+ viewModel.shouldShowRecordingGestureOverlay,
+ "A recording already in progress must keep driving the overlay regardless of other composer state."
+ )
+ }
+
// MARK: - Snackbar
func test_messageComposer_showRecordingTip_setsSnackBarText() {
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift
index 8793ac419..35c7e28e9 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift
@@ -1310,7 +1310,11 @@ import XCTest
private func makeComposerViewWithEditedMessage(_ editedMessage: ChatMessage) -> some View {
let factory = DefaultViewFactory.shared
let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient)
- let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil)
+ let viewModel = MessageComposerViewModel(
+ channelController: channelController,
+ messageController: nil,
+ editedMessage: .constant(editedMessage)
+ )
viewModel.attachmentsConverter = SynchronousAttachmentsConverter()
viewModel.fillEditedMessage(editedMessage)
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/WebView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/WebView_Tests.swift
index f651067c4..686994c77 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/WebView_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/WebView_Tests.swift
@@ -15,11 +15,11 @@ class WebView_Tests: StreamChatTestCase {
throw XCTSkip("Check it out: https://github.com/pointfreeco/swift-snapshot-testing/issues/625")
// Given
- let url = mockURL
+ let request = URLRequest(url: mockURL)
// When
let webView = WebView(
- url: url,
+ request: request,
isLoading: .constant(false),
title: .constant("Test"),
error: .constant(nil)
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_messageComposerView_frozenChannel.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_messageComposerView_frozenChannel.1.png
index 8d2ddf782..62dc6a33b 100644
Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_messageComposerView_frozenChannel.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageComposerView_Tests/test_messageComposerView_frozenChannel.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_customColors_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_customColors_snapshot.1.png
index 465894818..19097b11a 100644
Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_customColors_snapshot.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_customColors_snapshot.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_snapshot.1.png
index acbc1f2ad..e2cb78b14 100644
Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_snapshot.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_snapshot.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewGiphy_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewGiphy_snapshot.1.png
index 024c69743..299169121 100644
Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewGiphy_snapshot.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewGiphy_snapshot.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.default-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.default-light.png
index 9fd216e59..64a228ebb 100644
Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.default-light.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.default-light.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.extraExtraExtraLarge-light.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.extraExtraExtraLarge-light.png
index 7e7ba2366..ab204ec89 100644
Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.extraExtraExtraLarge-light.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.extraExtraExtraLarge-light.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.rightToLeftLayout-default.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.rightToLeftLayout-default.png
index c230578fb..327befbef 100644
Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.rightToLeftLayout-default.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.rightToLeftLayout-default.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.small-dark.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.small-dark.png
index 145875be9..bd8c47235 100644
Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.small-dark.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_messageViewPendingGiphy_snapshot.small-dark.png differ
diff --git a/StreamChatSwiftUITests/Tests/CommonViews/NukeImageLoader_Tests.swift b/StreamChatSwiftUITests/Tests/CommonViews/NukeImageLoader_Tests.swift
index d4836cced..8c2f1d41f 100644
--- a/StreamChatSwiftUITests/Tests/CommonViews/NukeImageLoader_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/CommonViews/NukeImageLoader_Tests.swift
@@ -3,11 +3,20 @@
//
@testable import StreamChat
+import StreamChatCommonUI
@testable import StreamChatSwiftUI
import XCTest
@MainActor
final class NukeImageLoader_Tests: StreamChatTestCase {
+ override func setUp() {
+ super.setUp()
+ // These tests exercise the real Nuke cache path, so the default
+ // snapshot-test sync lookup installed by `StreamChatTestCase` must
+ // be disabled.
+ NukeImageLoader.testSyncLookup = nil
+ }
+
// MARK: - cachedResult
func test_cachedResult_returnsNil_whenNoKeyStored() {
@@ -23,73 +32,13 @@ final class NukeImageLoader_Tests: StreamChatTestCase {
XCTAssertNil(result)
}
- // MARK: - loadImage (cache miss path)
-
- func test_loadImage_callsOnCacheMiss_whenImageNotCached() async throws {
- let url = URL(string: "https://example.com/test-miss-\(UUID().uuidString).jpg")!
- let cdnRequester = CDNRequester_Mock()
- var cacheMissCalled = false
-
- do {
- _ = try await NukeImageLoader.loadImage(
- url: url,
- resize: nil,
- cdnRequester: cdnRequester,
- onCacheMiss: { cacheMissCalled = true }
- )
- } catch {
- // Network error is expected in tests β we only care about the cache miss callback
- }
-
- XCTAssertTrue(cacheMissCalled)
- XCTAssertEqual(cdnRequester.imageRequestCallCount, 1)
- XCTAssertEqual(cdnRequester.imageRequestCalledWithURLs, [url])
- }
-
- func test_loadImage_callsCDNRequester_withCorrectURL() async {
- let url = URL(string: "https://example.com/cdn-check-\(UUID().uuidString).jpg")!
- let cdnRequester = CDNRequester_Mock()
-
- do {
- _ = try await NukeImageLoader.loadImage(
- url: url,
- resize: nil,
- cdnRequester: cdnRequester
- )
- } catch {
- // Expected
- }
-
- XCTAssertEqual(cdnRequester.imageRequestCallCount, 1)
- XCTAssertEqual(cdnRequester.imageRequestCalledWithURLs.first, url)
- }
-
- // MARK: - loadImage with resize
-
- func test_loadImage_passesCDNRequester_withResize() async {
- let url = URL(string: "https://example.com/resize-\(UUID().uuidString).jpg")!
- let cdnRequester = CDNRequester_Mock()
- let resize = ImageResize(CGSize(width: 200, height: 150))
+ // MARK: - storeCachingKey + cachedResult
- do {
- _ = try await NukeImageLoader.loadImage(
- url: url,
- resize: resize,
- cdnRequester: cdnRequester
- )
- } catch {
- // Expected
- }
-
- XCTAssertEqual(cdnRequester.imageRequestCallCount, 1)
- }
-
- // MARK: - loadImage (cache hit after CDN transform)
-
- func test_loadImage_skipsOnCacheMiss_whenCacheHitAfterTransform() async throws {
+ func test_cachedResult_returnsImage_afterStoringKeyAndPopulatingNukeCache() {
let testImage = UIImage(systemName: "star.fill")!
let uniqueKey = "cached-key-\(UUID().uuidString)"
let cdnURL = URL(string: "https://cdn.example.com/\(uniqueKey)")!
+ let originalURL = URL(string: "https://example.com/original-\(uniqueKey)")!
let request = ImageRequest(
url: cdnURL,
@@ -97,56 +46,43 @@ final class NukeImageLoader_Tests: StreamChatTestCase {
)
ImagePipeline.shared.cache[request] = ImageContainer(image: testImage)
- let cdnRequester = CDNRequester_Mock()
- cdnRequester.imageRequestResult = .success(
- CDNRequest(url: cdnURL, cachingKey: uniqueKey)
- )
-
- let originalURL = URL(string: "https://example.com/original-\(uniqueKey)")!
- var cacheMissCalled = false
-
- let result = try await NukeImageLoader.loadImage(
- url: originalURL,
- resize: nil,
- cdnRequester: cdnRequester,
- onCacheMiss: { cacheMissCalled = true }
- )
+ NukeImageLoader.storeCachingKey(uniqueKey, url: originalURL, resize: nil)
- XCTAssertFalse(cacheMissCalled)
- XCTAssertNotNil(result.image)
- XCTAssertEqual(cdnRequester.imageRequestCallCount, 1)
+ let cached = NukeImageLoader.cachedResult(url: originalURL, resize: nil)
+ XCTAssertNotNil(cached)
+ XCTAssertNotNil(cached?.image)
ImagePipeline.shared.cache[request] = nil
}
- // MARK: - cachingKeyMap persistence
+ func test_cachedResult_returnsNil_whenKeyStoredButNukeCacheEvicted() {
+ let uniqueKey = "evicted-key-\(UUID().uuidString)"
+ let originalURL = URL(string: "https://example.com/evicted-\(uniqueKey)")!
+
+ NukeImageLoader.storeCachingKey(uniqueKey, url: originalURL, resize: nil)
- func test_cachedResult_returnsImage_afterLoadStoresCachingKey() async throws {
+ let cached = NukeImageLoader.cachedResult(url: originalURL, resize: nil)
+ XCTAssertNil(cached)
+ }
+
+ func test_cachedResult_withResize_returnsImage() {
let testImage = UIImage(systemName: "heart.fill")!
- let uniqueKey = "persist-key-\(UUID().uuidString)"
+ let uniqueKey = "resize-key-\(UUID().uuidString)"
let cdnURL = URL(string: "https://cdn.example.com/\(uniqueKey)")!
- let originalURL = URL(string: "https://example.com/\(uniqueKey)")!
-
- let cdnRequester = CDNRequester_Mock()
- cdnRequester.imageRequestResult = .success(
- CDNRequest(url: cdnURL, cachingKey: uniqueKey)
- )
+ let originalURL = URL(string: "https://example.com/resize-\(uniqueKey)")!
+ let resize = ImageResize(CGSize(width: 200, height: 150))
let request = ImageRequest(
url: cdnURL,
+ processors: NukeImageLoader.makeProcessors(resize: resize),
userInfo: [.imageIdKey: uniqueKey]
)
ImagePipeline.shared.cache[request] = ImageContainer(image: testImage)
- _ = try await NukeImageLoader.loadImage(
- url: originalURL,
- resize: nil,
- cdnRequester: cdnRequester
- )
+ NukeImageLoader.storeCachingKey(uniqueKey, url: originalURL, resize: resize)
- let cached = NukeImageLoader.cachedResult(url: originalURL, resize: nil)
+ let cached = NukeImageLoader.cachedResult(url: originalURL, resize: resize)
XCTAssertNotNil(cached)
- XCTAssertNotNil(cached?.image)
ImagePipeline.shared.cache[request] = nil
}
diff --git a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift
index 0cdceec2e..c998ce23a 100644
--- a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift
+++ b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift
@@ -29,13 +29,28 @@ import XCTest
override open func setUp() {
super.setUp()
Appearance.bundle = Bundle(for: type(of: self))
+ let mediaLoader = MediaLoader_Mock()
streamChat = StreamChat(
chatClient: chatClient,
utils: Utils(
- mediaLoader: MediaLoader_Mock(),
+ mediaLoader: mediaLoader,
composerConfig: .init(isVoiceRecordingEnabled: true)
)
)
+ // Let StreamAsyncImage resolve mock image URLs synchronously so that
+ // snapshot tests capture the loaded image rather than the loading
+ // placeholder. `StreamAsyncImage` uses `.task` to load images, which
+ // completes after the snapshot is taken.
+ NukeImageLoader.testSyncLookup = { url, _ in
+ StreamAsyncImageResult(image: mediaLoader.imageForURL(url), animatedImageData: nil)
+ }
+ }
+
+ override open func tearDown() {
+ NukeImageLoader.testSyncLookup = nil
+ testWindow?.isHidden = true
+ testWindow = nil
+ super.tearDown()
}
func adjustAppearance(_ block: (inout Appearance) -> Void) {
@@ -70,12 +85,6 @@ import XCTest
hostingController.view.layoutIfNeeded()
return hostingController
}
-
- override open func tearDown() {
- testWindow?.isHidden = true
- testWindow = nil
- super.tearDown()
- }
}
// Forces the solid primary button style regardless of platform,
diff --git a/StreamChatSwiftUITests/Tests/Utils/MediaLoader_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/MediaLoader_Tests.swift
index 3010af98d..8a9777800 100644
--- a/StreamChatSwiftUITests/Tests/Utils/MediaLoader_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/Utils/MediaLoader_Tests.swift
@@ -11,7 +11,7 @@ import XCTest
class MediaLoader_Tests: StreamChatTestCase {
private let testURL = URL(string: "https://example.com/video.mp4")!
private let thumbnailURL = URL(string: "https://example.com/thumbnail.jpg")!
- private let cdnRequester = CDNRequester_Mock()
+ private let cdnRequester: CDNRequester = CDNRequester_Mock()
// MARK: - Custom conformer
@@ -21,7 +21,7 @@ class MediaLoader_Tests: StreamChatTestCase {
let expectation = expectation(description: "Completion called")
var receivedPreview: MediaLoaderVideoPreview?
- loader.loadVideoPreview(with: attachment, options: VideoLoadOptions(cdnRequester: cdnRequester)) { result in
+ loader.loadVideoPreview(with: attachment, options: VideoLoadOptions()) { result in
receivedPreview = try? result.get()
expectation.fulfill()
}
@@ -36,7 +36,7 @@ class MediaLoader_Tests: StreamChatTestCase {
let attachment = makeVideoAttachment(thumbnailURL: thumbnailURL)
let expectation = expectation(description: "Completion called")
- loader.loadVideoPreview(with: attachment, options: VideoLoadOptions(cdnRequester: cdnRequester)) { _ in
+ loader.loadVideoPreview(with: attachment, options: VideoLoadOptions()) { _ in
expectation.fulfill()
}
@@ -50,12 +50,12 @@ class MediaLoader_Tests: StreamChatTestCase {
func test_streamMediaLoader_loadImage_callsDownloader() {
let expectedImage = UIImage(systemName: "star.fill")!
let downloader = ConfigurableImageDownloader(result: .success(expectedImage))
- let mediaLoader = StreamMediaLoader(downloader: downloader)
+ let mediaLoader = StreamMediaLoader(downloader: downloader, cdnRequester: cdnRequester)
let url = URL(string: "https://example.com/image.jpg")!
let expectation = expectation(description: "Completion called")
var receivedImage: MediaLoaderImage?
- mediaLoader.loadImage(url: url, options: ImageLoadOptions(cdnRequester: cdnRequester)) { result in
+ mediaLoader.loadImage(url: url, options: ImageLoadOptions()) { result in
receivedImage = try? result.get()
expectation.fulfill()
}
@@ -67,11 +67,11 @@ class MediaLoader_Tests: StreamChatTestCase {
func test_streamMediaLoader_loadImage_returnsError_whenURLNil() {
let downloader = ConfigurableImageDownloader(result: .success(UIImage()))
- let mediaLoader = StreamMediaLoader(downloader: downloader)
+ let mediaLoader = StreamMediaLoader(downloader: downloader, cdnRequester: cdnRequester)
let expectation = expectation(description: "Completion called")
var receivedError: Error?
- mediaLoader.loadImage(url: nil, options: ImageLoadOptions(cdnRequester: cdnRequester)) { result in
+ mediaLoader.loadImage(url: nil, options: ImageLoadOptions()) { result in
if case let .failure(error) = result {
receivedError = error
}
@@ -86,12 +86,12 @@ class MediaLoader_Tests: StreamChatTestCase {
func test_streamMediaLoader_loadImage_propagatesDownloaderError() {
let expectedError = NSError(domain: "test", code: 42)
let downloader = ConfigurableImageDownloader(result: .failure(expectedError))
- let mediaLoader = StreamMediaLoader(downloader: downloader)
+ let mediaLoader = StreamMediaLoader(downloader: downloader, cdnRequester: cdnRequester)
let url = URL(string: "https://example.com/fail.jpg")!
let expectation = expectation(description: "Completion called")
var receivedError: NSError?
- mediaLoader.loadImage(url: url, options: ImageLoadOptions(cdnRequester: cdnRequester)) { result in
+ mediaLoader.loadImage(url: url, options: ImageLoadOptions()) { result in
if case let .failure(error) = result {
receivedError = error as NSError
}
@@ -107,12 +107,12 @@ class MediaLoader_Tests: StreamChatTestCase {
func test_streamMediaLoader_withAttachment_whenThumbnailURLExists_loadsThumbnailImage() {
let thumbnailImage = UIImage(systemName: "star.fill")!
let downloader = ConfigurableImageDownloader(result: .success(thumbnailImage))
- let mediaLoader = StreamMediaLoader(downloader: downloader)
+ let mediaLoader = StreamMediaLoader(downloader: downloader, cdnRequester: cdnRequester)
let attachment = makeVideoAttachment(thumbnailURL: thumbnailURL)
let expectation = expectation(description: "Completion called")
var receivedPreview: MediaLoaderVideoPreview?
- mediaLoader.loadVideoPreview(with: attachment, options: VideoLoadOptions(cdnRequester: cdnRequester)) { result in
+ mediaLoader.loadVideoPreview(with: attachment, options: VideoLoadOptions()) { result in
receivedPreview = try? result.get()
expectation.fulfill()
}
@@ -124,11 +124,11 @@ class MediaLoader_Tests: StreamChatTestCase {
func test_streamMediaLoader_withAttachment_whenNoThumbnailURL_doesNotCallImageDownloader() {
let downloader = ConfigurableImageDownloader(result: .success(UIImage()))
- let mediaLoader = StreamMediaLoader(downloader: downloader)
+ let mediaLoader = StreamMediaLoader(downloader: downloader, cdnRequester: cdnRequester)
let attachment = makeVideoAttachment(thumbnailURL: nil)
let expectation = expectation(description: "Completion called")
- mediaLoader.loadVideoPreview(with: attachment, options: VideoLoadOptions(cdnRequester: cdnRequester)) { _ in
+ mediaLoader.loadVideoPreview(with: attachment, options: VideoLoadOptions()) { _ in
expectation.fulfill()
}
@@ -136,6 +136,67 @@ class MediaLoader_Tests: StreamChatTestCase {
XCTAssertFalse(downloader.downloadImageCalled)
}
+ // MARK: - StreamMediaLoader loadFileRequest
+
+ func test_streamMediaLoader_loadFileRequest_returnsResolvedURL() {
+ let resolvedURL = URL(string: "https://cdn.example.com/signed?token=abc")!
+ let mock = CDNRequester_Mock()
+ mock.fileRequestResult = .success(CDNRequest(url: resolvedURL))
+ let mediaLoader = StreamMediaLoader(downloader: ConfigurableImageDownloader(result: .success(UIImage())), cdnRequester: mock)
+ let originalURL = URL(string: "https://example.com/file.pdf")!
+
+ let expectation = expectation(description: "Completion called")
+ var receivedRequest: MediaLoaderFileRequest?
+ mediaLoader.loadFileRequest(for: originalURL, options: DownloadFileRequestOptions()) { result in
+ receivedRequest = try? result.get()
+ expectation.fulfill()
+ }
+
+ waitForExpectations(timeout: 1)
+ XCTAssertEqual(receivedRequest?.urlRequest.url, resolvedURL)
+ XCTAssertEqual(mock.fileRequestCallCount, 1)
+ XCTAssertEqual(mock.fileRequestCalledWithURLs.first, originalURL)
+ }
+
+ func test_streamMediaLoader_loadFileRequest_forwardsHeaders() {
+ let resolvedURL = URL(string: "https://cdn.example.com/signed")!
+ let headers = ["Authorization": "Bearer token123", "X-Custom": "value"]
+ let mock = CDNRequester_Mock()
+ mock.fileRequestResult = .success(CDNRequest(url: resolvedURL, headers: headers))
+ let mediaLoader = StreamMediaLoader(downloader: ConfigurableImageDownloader(result: .success(UIImage())), cdnRequester: mock)
+
+ let expectation = expectation(description: "Completion called")
+ var receivedRequest: MediaLoaderFileRequest?
+ mediaLoader.loadFileRequest(for: URL(string: "https://example.com/file.pdf")!, options: DownloadFileRequestOptions()) { result in
+ receivedRequest = try? result.get()
+ expectation.fulfill()
+ }
+
+ waitForExpectations(timeout: 1)
+ let urlRequest = receivedRequest?.urlRequest
+ XCTAssertEqual(urlRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer token123")
+ XCTAssertEqual(urlRequest?.value(forHTTPHeaderField: "X-Custom"), "value")
+ }
+
+ func test_streamMediaLoader_loadFileRequest_propagatesError() {
+ let expectedError = NSError(domain: "test", code: 99)
+ let mock = CDNRequester_Mock()
+ mock.fileRequestResult = .failure(expectedError)
+ let mediaLoader = StreamMediaLoader(downloader: ConfigurableImageDownloader(result: .success(UIImage())), cdnRequester: mock)
+
+ let expectation = expectation(description: "Completion called")
+ var receivedError: NSError?
+ mediaLoader.loadFileRequest(for: URL(string: "https://example.com/file.pdf")!, options: DownloadFileRequestOptions()) { result in
+ if case let .failure(error) = result {
+ receivedError = error as NSError
+ }
+ expectation.fulfill()
+ }
+
+ waitForExpectations(timeout: 1)
+ XCTAssertEqual(receivedError?.code, 99)
+ }
+
// MARK: - Helpers
private func makeVideoAttachment(
@@ -197,6 +258,14 @@ private class CustomMediaLoader: MediaLoader, @unchecked Sendable {
) {
Task { @MainActor in completion(.success(MediaLoaderVideoPreview(image: UIImage()))) }
}
+
+ func loadFileRequest(
+ for url: URL,
+ options: DownloadFileRequestOptions,
+ completion: @escaping @MainActor (Result) -> Void
+ ) {
+ Task { @MainActor in completion(.success(MediaLoaderFileRequest(urlRequest: URLRequest(url: url)))) }
+ }
}
private class ConfigurableImageDownloader: ImageDownloading, @unchecked Sendable {
diff --git a/StreamChatSwiftUITests/Tests/Utils/StreamChat_Utils_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/StreamChat_Utils_Tests.swift
index ccb573a9e..269388057 100644
--- a/StreamChatSwiftUITests/Tests/Utils/StreamChat_Utils_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/Utils/StreamChat_Utils_Tests.swift
@@ -39,7 +39,7 @@ class StreamChat_Utils_Tests: StreamChatTestCase {
)
mediaLoader.loadVideoPreview(
with: attachment,
- options: VideoLoadOptions(cdnRequester: CDNRequester_Mock()),
+ options: VideoLoadOptions(),
completion: { _ in }
)
@@ -54,7 +54,7 @@ class StreamChat_Utils_Tests: StreamChatTestCase {
// When
mediaLoader.loadImage(
url: testURL,
- options: ImageLoadOptions(cdnRequester: CDNRequester_Mock()),
+ options: ImageLoadOptions(),
completion: { _ in }
)
@@ -62,25 +62,36 @@ class StreamChat_Utils_Tests: StreamChatTestCase {
XCTAssert(mediaLoader.loadImageCalled == true)
}
- func test_streamChatUtils_defaultCDNRequester() {
+ func test_streamChatUtils_defaultMediaLoader() {
// Given
let utils = Utils(mediaLoader: MediaLoader_Mock())
streamChat = StreamChat(chatClient: chatClient, utils: utils)
// Then
- XCTAssert(self.utils.cdnRequester is StreamCDNRequester)
+ XCTAssert(self.utils.mediaLoader is MediaLoader_Mock)
}
- func test_streamChatUtils_injectCustomCDNRequester() {
+ func test_streamChatUtils_defaultMediaLoader_hasDefaultCDNRequester() {
+ // Given
+ let utils = Utils()
+ streamChat = StreamChat(chatClient: chatClient, utils: utils)
+
+ // Then
+ let loader = self.utils.mediaLoader as? StreamMediaLoader
+ XCTAssertNotNil(loader)
+ XCTAssert(loader?.cdnRequester is StreamCDNRequester)
+ }
+
+ func test_streamChatUtils_customCDNRequester_throughMediaLoader() {
// Given
let customRequester = CDNRequester_Mock()
- let utils = Utils(
- cdnRequester: customRequester,
- mediaLoader: MediaLoader_Mock()
- )
+ let customLoader = StreamMediaLoader(downloader: StreamImageDownloader(), cdnRequester: customRequester)
+ let utils = Utils(mediaLoader: customLoader)
streamChat = StreamChat(chatClient: chatClient, utils: utils)
// Then
- XCTAssert(self.utils.cdnRequester is CDNRequester_Mock)
+ let loader = self.utils.mediaLoader as? StreamMediaLoader
+ XCTAssertNotNil(loader)
+ XCTAssert(loader?.cdnRequester is CDNRequester_Mock)
}
}
diff --git a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift
index ec1b453d8..b4d42a611 100644
--- a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift
@@ -549,6 +549,68 @@ import XCTest
XCTAssert(view is MessageReadIndicatorView)
}
+ func test_viewFactory_makeMessageReadIndicatorView_whenMessageDelivered_showsDelivered() throws {
+ // Given
+ let viewFactory = DefaultViewFactory.shared
+ let date = Date(timeIntervalSince1970: 100)
+ let message = ChatMessage.mock(
+ id: .unique,
+ cid: .unique,
+ text: "Test",
+ author: .mock(id: Self.currentUserId),
+ createdAt: date.addingTimeInterval(-100),
+ localState: nil,
+ isSentByCurrentUser: true
+ )
+ let channel = ChatChannel.mock(
+ cid: .unique,
+ reads: [
+ .mock(
+ lastReadAt: .distantPast,
+ lastReadMessageId: nil,
+ unreadMessagesCount: 0,
+ user: .mock(id: .unique),
+ lastDeliveredAt: date,
+ lastDeliveredMessageId: message.id
+ )
+ ]
+ )
+
+ // When
+ let view = viewFactory.makeMessageReadIndicatorView(
+ options: MessageReadIndicatorViewOptions(channel: channel, message: message)
+ )
+
+ // Then
+ let indicator = try XCTUnwrap(view as? MessageReadIndicatorView)
+ XCTAssertTrue(indicator.showDelivered)
+ XCTAssertTrue(indicator.readUsers.isEmpty)
+ }
+
+ func test_viewFactory_makeMessageReadIndicatorView_whenMessageNotDelivered_doesNotShowDelivered() throws {
+ // Given
+ let viewFactory = DefaultViewFactory.shared
+ let message = ChatMessage.mock(
+ id: .unique,
+ cid: .unique,
+ text: "Test",
+ author: .mock(id: Self.currentUserId),
+ createdAt: Date(timeIntervalSince1970: 100),
+ localState: nil,
+ isSentByCurrentUser: true
+ )
+ let channel = ChatChannel.mock(cid: .unique)
+
+ // When
+ let view = viewFactory.makeMessageReadIndicatorView(
+ options: MessageReadIndicatorViewOptions(channel: channel, message: message)
+ )
+
+ // Then
+ let indicator = try XCTUnwrap(view as? MessageReadIndicatorView)
+ XCTAssertFalse(indicator.showDelivered)
+ }
+
func test_viewFactory_makeSystemMessageView() {
// Given
let viewFactory = DefaultViewFactory.shared