diff --git a/.github/workflows/cron-checks.yml b/.github/workflows/cron-checks.yml
index 85c8cac45..8c3cb97f6 100644
--- a/.github/workflows/cron-checks.yml
+++ b/.github/workflows/cron-checks.yml
@@ -21,23 +21,24 @@ jobs:
strategy:
matrix:
include:
- - ios: 18.5
- xcode: 16.4
- os: macos-15
+ # - ios: "26.0" TODO: IOS-1181
+ # device: "iPhone 17 Pro"
+ # xcode: "26.0.1"
+ # setup_runtime: false
+ - ios: "18.5"
device: "iPhone 16 Pro"
+ xcode: "26.0.1"
setup_runtime: false
- - ios: 17.5
- xcode: 15.4
- os: macos-14
+ - ios: "17.5"
device: "iPhone 15 Pro"
- setup_runtime: false
- - ios: 16.4
- xcode: 15.3 # fails on 15.4
- os: macos-14
+ xcode: "26.0.1"
+ setup_runtime: true
+ - ios: "16.4"
device: "iPhone 14 Pro"
+ xcode: "16.4"
setup_runtime: true
fail-fast: false
- runs-on: ${{ matrix.os }}
+ runs-on: macos-15
env:
GITHUB_EVENT: ${{ toJson(github.event) }}
ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }}
@@ -91,9 +92,11 @@ jobs:
strategy:
matrix:
include:
- - xcode: 16.4
+ - xcode: 26.0.1 # swift 6.2
+ os: macos-15
+ - xcode: 16.4 # swift 6.1
os: macos-15
- - xcode: 16.1
+ - xcode: 16.1 # swift 6.0
os: macos-14
fail-fast: false
runs-on: ${{ matrix.os }}
@@ -107,20 +110,20 @@ jobs:
XCODE_VERSION: ${{ matrix.xcode }}
build-old-xcode:
- name: Build SDKs (Xcode 15)
+ name: Build SDKs (Old Xcode)
runs-on: macos-14
env:
- XCODE_VERSION: "15.4"
+ XCODE_VERSION: "16.1"
steps:
- 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/xcode-cache
- uses: ./.github/actions/ruby-cache
- timeout-minutes: 25
- name: Build SwiftUI
- run: bundle exec fastlane test_ui device:"iPhone 15" build_for_testing:true
+ run: bundle exec fastlane test_ui device:"iPhone 16" build_for_testing:true
timeout-minutes: 25
- name: Build XCFrameworks
run: bundle exec fastlane build_xcframeworks
@@ -133,7 +136,7 @@ jobs:
name: Automated Code Review
runs-on: macos-14
env:
- XCODE_VERSION: "15.4"
+ XCODE_VERSION: "16.1"
steps:
- uses: actions/checkout@v4.1.1
- uses: ./.github/actions/bootstrap
diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml
index 2ac0f5b2b..1181519d8 100644
--- a/.github/workflows/smoke-checks.yml
+++ b/.github/workflows/smoke-checks.yml
@@ -50,7 +50,7 @@ jobs:
runs-on: macos-14
if: ${{ github.event.inputs.record_snapshots != 'true' }}
env:
- XCODE_VERSION: "15.4"
+ XCODE_VERSION: "16.1"
steps:
- uses: actions/checkout@v4.1.1
- uses: ./.github/actions/bootstrap
@@ -63,22 +63,22 @@ jobs:
- run: bundle exec fastlane pod_lint
if: startsWith(github.event.pull_request.head.ref, 'release/')
- build-xcode15:
- name: Build SDKs (Xcode 15)
+ build-old-xcode:
+ name: Build SDKs (Old Xcode)
runs-on: macos-14
if: ${{ github.event.inputs.record_snapshots != 'true' }}
env:
- XCODE_VERSION: "15.4"
+ XCODE_VERSION: "16.1"
steps:
- 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/xcode-cache
- uses: ./.github/actions/ruby-cache
- timeout-minutes: 25
- name: Build SwiftUI
- run: bundle exec fastlane test_ui device:"iPhone 15" build_for_testing:true
+ run: bundle exec fastlane test_ui device:"iPhone 16" build_for_testing:true
timeout-minutes: 25
- name: Build XCFrameworks
run: bundle exec fastlane build_xcframeworks
@@ -90,6 +90,10 @@ jobs:
test-ui-debug:
name: Test SwiftUI (Debug)
runs-on: macos-15
+ env:
+ GITHUB_TOKEN: ${{ secrets.CI_BOT_GITHUB_TOKEN }} # to open a PR
+ IOS_SIMULATOR_DEVICE: "iPhone 16 Pro (18.5)" # TODO: IOS-1181
+ XCODE_VERSION: "16.4" # TODO: IOS-1181
steps:
- uses: actions/checkout@v4.1.1
- uses: ./.github/actions/bootstrap
@@ -99,8 +103,6 @@ jobs:
- name: Run UI Tests (Debug)
run: bundle exec fastlane test_ui device:"${{ env.IOS_SIMULATOR_DEVICE }}" record:"${{ github.event.inputs.record_snapshots }}"
timeout-minutes: 120
- env:
- GITHUB_TOKEN: ${{ secrets.CI_BOT_GITHUB_TOKEN }} # to open a PR
- name: Run Sonar analysis
if: ${{ github.event.inputs.record_snapshots != 'true' }}
run: bundle exec fastlane sonar_upload
@@ -157,6 +159,8 @@ jobs:
- build-test-app-and-frameworks
env:
LAUNCH_ID: ${{ needs.allure_testops_launch.outputs.launch_id }}
+ IOS_SIMULATOR_DEVICE: "iPhone 16 Pro (18.5)" # TODO: IOS-1181
+ XCODE_VERSION: "16.4" # TODO: IOS-1181
strategy:
matrix:
batch: [0, 1]
diff --git a/.swiftformat b/.swiftformat
index 49e9dfeae..09b4ef180 100644
--- a/.swiftformat
+++ b/.swiftformat
@@ -2,20 +2,66 @@
--header "\nCopyright © {year} Stream.io Inc. All rights reserved.\n"
--swiftversion 5.9
---ifdef no-indent
---disable redundantType
---disable extensionAccessControl
---disable andOperator
---disable hoistPatternLet
---disable typeSugar
-
---disable redundantGet # it removes get async throws from getters
+# Use allow-list
+--rules blankLinesAroundMark
+--rules blankLinesAtEndOfScope
+--rules blankLinesAtStartOfScope
+--rules blankLinesBetweenScopes
+--rules braces
+--rules consecutiveBlankLines
+--rules consecutiveSpaces
+--rules duplicateImports
+--rules elseOnSameLine
+--rules emptyBraces
+--rules enumNamespaces
+--rules fileHeader
+--rules indent
+--rules initCoderUnavailable
+--rules isEmpty
+--rules leadingDelimiters
+--rules linebreakAtEndOfFile
+--rules linebreaks
+--rules modifierOrder
+--rules numberFormatting
+--rules redundantBackticks
+--rules redundantBreak
+--rules redundantExtensionACL
+--rules redundantFileprivate
+--rules redundantLet
+--rules redundantLetError
+--rules redundantNilInit
+--rules redundantObjc
+--rules redundantPattern
+--rules redundantRawValues
+--rules redundantVoidReturnType
+--rules semicolons
+--rules sortedImports
+--rules spaceAroundBraces
+--rules spaceAroundBrackets
+--rules spaceAroundComments
+--rules spaceAroundGenerics
+--rules spaceAroundOperators
+--rules spaceAroundParens
+--rules spaceInsideBraces
+--rules spaceInsideBrackets
+--rules spaceInsideComments
+--rules spaceInsideGenerics
+--rules spaceInsideParens
+--rules strongOutlets
+--rules strongifiedSelf
+--rules todos
+--rules trailingCommas
+--rules trailingSpace
+--rules unusedArguments
+--rules void
+--rules wrap
+--rules wrapArguments
+--rules wrapAttributes
+--rules yodaConditions
-# Rules inferred from Swift Standard Library:
---disable anyObjectProtocol, wrapMultilineStatementBraces
+# Configuration for enabled rules
+--ifdef no-indent
--indent 4
---enable isEmpty
---disable redundantParens # it generates mistakes for e.g. "if (a || b), let x = ... {}"
--semicolons inline
--nospaceoperators ..., ..< # what about ==, +=?
--commas inline
diff --git a/.swiftlint.yml b/.swiftlint.yml
index f172fee13..6a5da8f1b 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -7,7 +7,6 @@ excluded:
- Sources/StreamChatSwiftUI/StreamNuke
only_rules:
- # Currently enabled autocorrectable rules
- attribute_name_spacing
- closing_brace
- colon
@@ -19,7 +18,7 @@ only_rules:
- empty_enum_arguments
- empty_parameters
- empty_parentheses_with_trailing_closure
- - explicit_init
+ - file_name_no_space
- joined_default_parameter
- leading_whitespace
- legacy_cggeometry_functions
@@ -27,6 +26,7 @@ only_rules:
- legacy_constructor
- legacy_nsgeometry_functions
- mark
+ - multiline_arguments
- no_space_in_method_call
- prefer_type_checking
- private_over_fileprivate
@@ -52,5 +52,11 @@ only_rules:
- vertical_whitespace
- void_return
+multiline_arguments:
+ only_enforce_after_first_closure_on_first_line: true
+
trailing_whitespace:
- ignores_empty_lines: true
\ No newline at end of file
+ ignores_empty_lines: true
+
+file_name_no_space:
+ severity: error
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 134e27da8..139f0c9d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### 🔄 Changed
+# [4.90.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.90.0)
+_October 08, 2025_
+
+### ✅ Added
+- Opens the `commandsHandler` and makes the mention methods public [#979](https://github.com/GetStream/stream-chat-swiftui/pull/979)
+- Opens `MarkdownFormatter` so that it can be customised [#978](https://github.com/GetStream/stream-chat-swiftui/pull/978)
+- Add participant actions in channel info view [#982](https://github.com/GetStream/stream-chat-swiftui/pull/982)
+- Add support for overriding `onImageTap` in `LinkAttachmentView` [#986](https://github.com/GetStream/stream-chat-swiftui/pull/986)
+- Add support for customizing text colors in `LinkAttachmentView` [#992](https://github.com/GetStream/stream-chat-swiftui/pull/992)
+- Expose `MediaAttachment` properties and initializer [#1000](https://github.com/GetStream/stream-chat-swiftui/pull/1000)
+- Add `ColorPalette.navigationBarGlyph` for configuring the glyph color for buttons in navigation bars [#999](https://github.com/GetStream/stream-chat-swiftui/pull/999)
+- Allow overriding `ChatChannelInfoViewModel` properties: `shouldShowLeaveConversationButton`, `canRenameChannel`, and `shouldShowAddUserButton` [#995](https://github.com/GetStream/stream-chat-swiftui/pull/995)
+
+### 🐞 Fixed
+- Fix openChannel not working when searching or another chat shown [#975](https://github.com/GetStream/stream-chat-swiftui/pull/975)
+- Fix crash when using a font that does not support bold or italic trait [#976](https://github.com/GetStream/stream-chat-swiftui/pull/976)
+- Fix unread messages banner not shown for one-page channels [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
+- Fix unread messages banner not shown if the whole channel is unread [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
+- Fix channel not marking read when passing by the unread message [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
+- Fix random scroll after marking a message unread [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
+- Fix marking channel read when the user scrolls to the bottom after marking a message as unread [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
+- Fix replying to unread messages marking them instantly as read [#989](https://github.com/GetStream/stream-chat-swiftui/pull/989)
+- Fix rendering of the add users button on iOS 26 [#999](https://github.com/GetStream/stream-chat-swiftui/pull/999)
+- Use `ColorPalette.navigationBarTint` for the background of the add users button [#999](https://github.com/GetStream/stream-chat-swiftui/pull/999)
+- Fix showing all the channel members in the more channel actions view [#1001](https://github.com/GetStream/stream-chat-swiftui/pull/1001)
+
# [4.89.1](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.89.1)
_September 23, 2025_
diff --git a/DemoAppSwiftUI/ChannelHeader/CustomChannelHeader.swift b/DemoAppSwiftUI/ChannelHeader/CustomChannelHeader.swift
index 6778246d0..b3e7dc1dc 100644
--- a/DemoAppSwiftUI/ChannelHeader/CustomChannelHeader.swift
+++ b/DemoAppSwiftUI/ChannelHeader/CustomChannelHeader.swift
@@ -31,7 +31,7 @@ public struct CustomChannelHeader: ToolbarContent {
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
- .foregroundColor(Color.white)
+ .foregroundColor(Color(colors.navigationBarGlyph))
.padding(.all, 8)
.background(colors.navigationBarTintColor)
.clipShape(Circle())
@@ -75,7 +75,7 @@ struct CustomChannelModifier: ChannelListHeaderViewModifier {
actionsPopupShown: $actionsPopupShown
)
#if compiler(>=6.2)
- .sharedBackgroundVisibility(.hidden)
+ .sharedBackgroundVisibility(.hidden)
#endif
}
} else {
diff --git a/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift b/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift
index 7ebaad870..86f8edd8e 100644
--- a/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift
+++ b/DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift
@@ -97,7 +97,7 @@ class NewChatViewModel: ObservableObject, ChatUserSearchControllerDelegate {
if !loadingNextUsers {
loadingNextUsers = true
- searchController.loadNextUsers(limit: 50) { [weak self] _ in
+ searchController.loadNextUsers { [weak self] _ in
guard let self = self else { return }
self.chatUsers = self.searchController.userArray
self.loadingNextUsers = false
diff --git a/Githubfile b/Githubfile
index 79a16c78c..234bff136 100644
--- a/Githubfile
+++ b/Githubfile
@@ -6,6 +6,6 @@ export YEETD_VERSION='1.0'
export SONAR_VERSION='6.2.1.4610'
export IPSW_VERSION='3.1.592'
export INTERFACE_ANALYZER_VERSION='1.0.7'
-export SWIFT_LINT_VERSION='0.55.1'
-export SWIFT_FORMAT_VERSION='0.47.12'
+export SWIFT_LINT_VERSION='0.59.1'
+export SWIFT_FORMAT_VERSION='0.58.2'
export SWIFT_GEN_VERSION='6.5.1'
diff --git a/Package.swift b/Package.swift
index 0bc5d6053..0e96f688f 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: "4.89.0")
+ .package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "4.90.0")
],
targets: [
.target(
diff --git a/README.md b/README.md
index 5069d2526..de473ce8d 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
## SwiftUI StreamChat SDK
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift
index 9ef21fd8a..ca1b1b840 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift
@@ -118,7 +118,7 @@ public struct DefaultChannelHeaderModifier: ChatChannelHea
isActive: $isActive
)
#if compiler(>=6.2)
- .sharedBackgroundVisibility(.hidden)
+ .sharedBackgroundVisibility(.hidden)
#endif
}
} else {
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoView.swift
index 1b1ec098c..a6a8837dc 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoView.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoView.swift
@@ -20,11 +20,12 @@ public struct ChatChannelInfoView: View, KeyboardReadable
public init(
factory: Factory = DefaultViewFactory.shared,
+ viewModel: ChatChannelInfoViewModel? = nil,
channel: ChatChannel,
shownFromMessageList: Bool = false
) {
_viewModel = StateObject(
- wrappedValue: ChatChannelInfoViewModel(channel: channel)
+ wrappedValue: viewModel ?? ChatChannelInfoViewModel(channel: channel)
)
self.factory = factory
self.shownFromMessageList = shownFromMessageList
@@ -52,7 +53,8 @@ public struct ChatChannelInfoView: View, KeyboardReadable
ChatInfoParticipantsView(
factory: factory,
participants: viewModel.displayedParticipants,
- onItemAppear: viewModel.onParticipantAppear(_:)
+ onItemAppear: viewModel.onParticipantAppear(_:),
+ selectedParticipant: $viewModel.selectedParticipant
)
}
@@ -84,14 +86,10 @@ public struct ChatChannelInfoView: View, KeyboardReadable
viewModel.leaveGroupAlertShown = true
}
.alert(isPresented: $viewModel.leaveGroupAlertShown) {
- let title = viewModel.leaveButtonTitle
- let message = viewModel.leaveConversationDescription
- let buttonTitle = viewModel.leaveButtonTitle
-
- return Alert(
- title: Text(title),
- message: Text(message),
- primaryButton: .destructive(Text(buttonTitle)) {
+ Alert(
+ title: Text(viewModel.leaveButtonTitle),
+ message: Text(viewModel.leaveConversationDescription),
+ primaryButton: .destructive(Text(viewModel.leaveButtonTitle)) {
viewModel.leaveConversationTapped {
presentationMode.wrappedValue.dismiss()
if shownFromMessageList {
@@ -106,11 +104,11 @@ public struct ChatChannelInfoView: View, KeyboardReadable
}
}
.overlay(
- viewModel.addUsersShown ?
+ popupShown ?
Color.black.opacity(0.3).edgesIgnoringSafeArea(.all) : nil
)
- .blur(radius: viewModel.addUsersShown ? 6 : 0)
- .allowsHitTesting(!viewModel.addUsersShown)
+ .blur(radius: popupShown ? 6 : 0)
+ .allowsHitTesting(!popupShown)
if viewModel.addUsersShown {
VStack {
@@ -131,43 +129,85 @@ public struct ChatChannelInfoView: View, KeyboardReadable
)
}
}
- }
- .toolbarThemed {
- ToolbarItem(placement: .principal) {
- Group {
- if viewModel.showSingleMemberDMView {
- Text(viewModel.displayedParticipants.first?.chatUser.name ?? "")
- .font(fonts.bodyBold)
- .foregroundColor(Color(colors.navigationBarTitle))
- } else {
- ChannelTitleView(
- channel: viewModel.channel,
- shouldShowTypingIndicator: false
- )
- .id(viewModel.channelId)
- }
- }
- }
-
- ToolbarItem(placement: .navigationBarTrailing) {
- if viewModel.shouldShowAddUserButton {
- Button {
- viewModel.addUsersShown = true
- } label: {
- Image(systemName: "person.badge.plus")
- .customizable()
- .foregroundColor(Color.white)
- .padding(.all, 8)
- .background(colors.tintColor)
- .clipShape(Circle())
+
+ if let selectedParticipant = viewModel.selectedParticipant {
+ ParticipantInfoView(
+ participant: selectedParticipant,
+ actions: viewModel.participantActions(for: selectedParticipant)
+ ) {
+ withAnimation {
+ viewModel.selectedParticipant = nil
}
}
}
}
+ .modifier(ChatChannelInfoViewHeaderViewModifier(viewModel: viewModel))
.onReceive(keyboardWillChangePublisher) { visible in
viewModel.keyboardShown = visible
}
.dismissKeyboardOnTap(enabled: viewModel.keyboardShown)
.background(Color(colors.background).edgesIgnoringSafeArea(.bottom))
}
+
+ private var popupShown: Bool {
+ viewModel.addUsersShown || viewModel.selectedParticipant != nil
+ }
+}
+
+struct ChatChannelInfoViewHeaderViewModifier: ViewModifier {
+ @Injected(\.colors) private var colors
+ @Injected(\.fonts) private var fonts
+
+ let viewModel: ChatChannelInfoViewModel
+
+ func body(content: Content) -> some View {
+ if #available(iOS 26.0, *) {
+ content
+ .toolbarThemed {
+ toolbar(glyphSize: 24)
+ #if compiler(>=6.2)
+ .sharedBackgroundVisibility(.hidden)
+ #endif
+ }
+ } else {
+ content
+ .toolbarThemed {
+ toolbar()
+ }
+ }
+ }
+
+ @ToolbarContentBuilder func toolbar(glyphSize: CGFloat? = nil) -> some ToolbarContent {
+ ToolbarItem(placement: .principal) {
+ Group {
+ if viewModel.showSingleMemberDMView {
+ Text(viewModel.displayedParticipants.first?.chatUser.name ?? "")
+ .font(fonts.bodyBold)
+ .foregroundColor(Color(colors.navigationBarTitle))
+ } else {
+ ChannelTitleView(
+ channel: viewModel.channel,
+ shouldShowTypingIndicator: false
+ )
+ .id(viewModel.channelId)
+ }
+ }
+ }
+
+ ToolbarItem(placement: .navigationBarTrailing) {
+ if viewModel.shouldShowAddUserButton {
+ Button {
+ viewModel.addUsersShown = true
+ } label: {
+ Image(systemName: "person.badge.plus")
+ .customizable()
+ .frame(width: glyphSize, height: glyphSize)
+ .foregroundColor(Color(colors.navigationBarGlyph))
+ .padding(.all, 8)
+ .background(colors.navigationBarTintColor)
+ .clipShape(Circle())
+ }
+ }
+ }
+ }
}
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift
index ab17499df..4c9051f5a 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift
@@ -7,7 +7,7 @@ import StreamChat
import SwiftUI
// View model for the `ChatChannelInfoView`.
-public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate {
+open class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate {
@Injected(\.chatClient) private var chatClient
@Published public var participants = [ParticipantInfo]()
@@ -34,28 +34,31 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
@Published public var channelId = UUID().uuidString
@Published public var keyboardShown = false
@Published public var addUsersShown = false
-
- public var shouldShowLeaveConversationButton: Bool {
+ @Published public var selectedParticipant: ParticipantInfo?
+
+ open var shouldShowLeaveConversationButton: Bool {
if channel.isDirectMessageChannel {
- return channel.ownCapabilities.contains(.deleteChannel)
+ channel.ownCapabilities.contains(.deleteChannel)
} else {
- return channel.ownCapabilities.contains(.leaveChannel)
+ channel.ownCapabilities.contains(.leaveChannel)
}
}
- public var canRenameChannel: Bool {
+ open var canRenameChannel: Bool {
channel.ownCapabilities.contains(.updateChannel)
}
- public var shouldShowAddUserButton: Bool {
+ open var shouldShowAddUserButton: Bool {
if channel.isDirectMessageChannel {
- return false
+ false
} else {
- return channel.ownCapabilities.contains(.updateChannelMembers)
+ channel.ownCapabilities.contains(.updateChannelMembers)
}
}
var channelController: ChatChannelController!
+ var currentUserController: CurrentChatUserController?
+
private var memberListController: ChatChannelMemberListController!
private var loadingUsers = false
@@ -71,7 +74,7 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
return [otherParticipant]
}
- let participants = self.participants.filter { $0.isDeactivated == false }
+ let participants = participants.filter { $0.isDeactivated == false }
if participants.count <= 6 {
return participants
@@ -86,17 +89,17 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
public var leaveButtonTitle: String {
if channel.isDirectMessageChannel {
- return L10n.Alert.Actions.deleteChannelTitle
+ L10n.Alert.Actions.deleteChannelTitle
} else {
- return L10n.Alert.Actions.leaveGroupTitle
+ L10n.Alert.Actions.leaveGroupTitle
}
}
public var leaveConversationDescription: String {
if channel.isDirectMessageChannel {
- return L10n.Alert.Actions.deleteChannelMessage
+ L10n.Alert.Actions.deleteChannelMessage
} else {
- return L10n.Alert.Actions.leaveGroupMessage
+ L10n.Alert.Actions.leaveGroupMessage
}
}
@@ -107,7 +110,7 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
public var notDisplayedParticipantsCount: Int {
let total = channel.memberCount
let displayed = displayedParticipants.count
- let deactivated = participants.filter { $0.isDeactivated }.count
+ let deactivated = participants.filter(\.isDeactivated).count
return total - displayed - deactivated
}
@@ -129,6 +132,8 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
memberListController = chatClient.memberListController(
query: .init(cid: channel.cid, filter: .none)
)
+ currentUserController = chatClient.currentUserController()
+ currentUserController?.synchronize()
participants = channel.lastActiveMembers.map { member in
ParticipantInfo(
@@ -142,12 +147,12 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
public func onlineInfo(for user: ChatUser) -> String {
if user.isOnline {
- return L10n.Message.Title.online
+ L10n.Message.Title.online
} else if let lastActiveAt = user.lastActiveAt,
let timeAgo = lastSeenDateFormatter(lastActiveAt) {
- return timeAgo
+ timeAgo
} else {
- return L10n.Message.Title.offline
+ L10n.Message.Title.offline
}
}
@@ -156,7 +161,7 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
return
}
- let displayedParticipants = self.displayedParticipants
+ let displayedParticipants = displayedParticipants
if displayedParticipants.isEmpty {
loadAdditionalUsers()
return
@@ -204,15 +209,13 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
) {
if let channel = channelController.channel {
self.channel = channel
- if self.channel.lastActiveMembers.count > participants.count {
- participants = channel.lastActiveMembers.map { member in
- ParticipantInfo(
- chatUser: member,
- displayName: member.name ?? member.id,
- onlineInfoText: onlineInfo(for: member),
- isDeactivated: member.isDeactivated
- )
- }
+ participants = channel.lastActiveMembers.map { member in
+ ParticipantInfo(
+ chatUser: member,
+ displayName: member.name ?? member.id,
+ onlineInfoText: onlineInfo(for: member),
+ isDeactivated: member.isDeactivated
+ )
}
}
}
@@ -252,10 +255,10 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
loadingUsers = true
memberListController.loadNextMembers { [weak self] error in
- guard let self = self else { return }
- self.loadingUsers = false
+ guard let self else { return }
+ loadingUsers = false
if error == nil {
- let newMembers = self.memberListController.members.map { member in
+ let newMembers = memberListController.members.map { member in
ParticipantInfo(
chatUser: member,
displayName: member.name ?? member.id,
@@ -263,8 +266,8 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
isDeactivated: member.isDeactivated
)
}
- if newMembers.count > self.participants.count {
- self.participants = newMembers
+ if newMembers.count > participants.count {
+ participants = newMembers
}
}
}
@@ -273,4 +276,175 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
private var lastSeenDateFormatter: (Date) -> String? {
DateUtils.timeAgo
}
+
+ open func participantActions(for participant: ParticipantInfo) -> [ParticipantAction] {
+ var actions = [ParticipantAction]()
+
+ var directMessageAction = ParticipantAction(
+ title: L10n.Channel.Item.sendDirectMessage,
+ iconName: "message.circle.fill",
+ action: {},
+ confirmationPopup: nil,
+ isDestructive: false
+ )
+ if let currentUserId = chatClient.currentUserId,
+ let channelController = try? chatClient.channelController(
+ createDirectMessageChannelWith: [currentUserId, participant.id],
+ extraData: [:]
+ ) {
+ directMessageAction.navigationDestination = AnyView(
+ ChatChannelView(channelController: channelController)
+ )
+
+ actions.append(directMessageAction)
+ }
+
+ if channel.config.mutesEnabled {
+ let mutedUsers = currentUserController?.currentUser?.mutedUsers ?? []
+ if mutedUsers.contains(participant.chatUser) == true {
+ let unmuteUser = unmuteAction(
+ participant: participant,
+ onDismiss: handleParticipantActionDismiss,
+ onError: handleParticipantActionError
+ )
+ actions.append(unmuteUser)
+ } else {
+ let muteUser = muteAction(
+ participant: participant,
+ onDismiss: handleParticipantActionDismiss,
+ onError: handleParticipantActionError
+ )
+ actions.append(muteUser)
+ }
+ }
+
+ if channel.canUpdateChannelMembers {
+ let removeUserAction = removeUserAction(
+ participant: participant,
+ onDismiss: handleParticipantActionDismiss,
+ onError: handleParticipantActionError
+ )
+ actions.append(removeUserAction)
+ }
+
+ let cancel = ParticipantAction(
+ title: L10n.Alert.Actions.cancel,
+ iconName: "xmark.circle",
+ action: { [weak self] in
+ self?.selectedParticipant = nil
+ },
+ confirmationPopup: nil,
+ isDestructive: false
+ )
+
+ actions.append(cancel)
+
+ return actions
+ }
+
+ public func muteAction(
+ participant: ParticipantInfo,
+ onDismiss: @escaping () -> Void,
+ onError: @escaping (Error) -> Void
+ ) -> ParticipantAction {
+ let muteAction = { [weak self] in
+ let controller = self?.chatClient.userController(userId: participant.id)
+ controller?.mute { error in
+ if let error {
+ onError(error)
+ } else {
+ onDismiss()
+ }
+ }
+ }
+ let confirmationPopup = ConfirmationPopup(
+ title: "\(L10n.Channel.Item.mute) \(participant.displayName)",
+ message: "\(L10n.Alert.Actions.muteChannelTitle) \(participant.displayName)?",
+ buttonTitle: L10n.Channel.Item.mute
+ )
+ let muteUser = ParticipantAction(
+ title: "\(L10n.Channel.Item.mute) \(participant.displayName)",
+ iconName: "speaker.slash",
+ action: muteAction,
+ confirmationPopup: confirmationPopup,
+ isDestructive: false
+ )
+ return muteUser
+ }
+
+ public func unmuteAction(
+ participant: ParticipantInfo,
+ onDismiss: @escaping () -> Void,
+ onError: @escaping (Error) -> Void
+ ) -> ParticipantAction {
+ let unMuteAction = { [weak self] in
+ let controller = self?.chatClient.userController(userId: participant.id)
+ controller?.unmute { error in
+ if let error {
+ onError(error)
+ } else {
+ onDismiss()
+ }
+ }
+ }
+ let confirmationPopup = ConfirmationPopup(
+ title: "\(L10n.Channel.Item.unmute) \(participant.displayName)",
+ message: "\(L10n.Alert.Actions.unmuteChannelTitle) \(participant.displayName)?",
+ buttonTitle: L10n.Channel.Item.unmute
+ )
+ let unmuteUser = ParticipantAction(
+ title: "\(L10n.Channel.Item.unmute) \(participant.displayName)",
+ iconName: "speaker.wave.1",
+ action: unMuteAction,
+ confirmationPopup: confirmationPopup,
+ isDestructive: false
+ )
+
+ return unmuteUser
+ }
+
+ public func removeUserAction(
+ participant: ParticipantInfo,
+ onDismiss: @escaping () -> Void,
+ onError: @escaping (Error) -> Void
+ ) -> ParticipantAction {
+ let action = { [weak self] in
+ guard let self else {
+ onError(ClientError.Unexpected("Self is nil"))
+ return
+ }
+ let controller = chatClient.channelController(for: channel.cid)
+ controller.removeMembers(userIds: [participant.id]) { error in
+ if let error {
+ onError(error)
+ } else {
+ onDismiss()
+ }
+ }
+ }
+
+ let confirmationPopup = ConfirmationPopup(
+ title: L10n.Channel.Item.removeUserConfirmationTitle,
+ message: L10n.Channel.Item.removeUserConfirmationMessage(participant.displayName, channel.name ?? channel.id),
+ buttonTitle: L10n.Channel.Item.removeUser
+ )
+
+ let removeUserAction = ParticipantAction(
+ title: L10n.Channel.Item.removeUser,
+ iconName: "person.slash",
+ action: action,
+ confirmationPopup: confirmationPopup,
+ isDestructive: true
+ )
+
+ return removeUserAction
+ }
+
+ func handleParticipantActionDismiss() {
+ selectedParticipant = nil
+ }
+
+ func handleParticipantActionError(_ error: Error?) {
+ errorShown = true
+ }
}
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatInfoParticipantsView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatInfoParticipantsView.swift
index 47979b01c..2bf11a91e 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatInfoParticipantsView.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatInfoParticipantsView.swift
@@ -7,8 +7,11 @@ import SwiftUI
/// View for the chat info participants.
public struct ChatInfoParticipantsView: View {
+ @Injected(\.chatClient) private var chatClient
@Injected(\.fonts) private var fonts
@Injected(\.colors) private var colors
+
+ @Binding var selectedParticipant: ParticipantInfo?
let factory: Factory
var participants: [ParticipantInfo]
@@ -17,11 +20,13 @@ public struct ChatInfoParticipantsView: View {
public init(
factory: Factory = DefaultViewFactory.shared,
participants: [ParticipantInfo],
- onItemAppear: @escaping (ParticipantInfo) -> Void
+ onItemAppear: @escaping (ParticipantInfo) -> Void,
+ selectedParticipant: Binding = .constant(nil)
) {
self.factory = factory
self.participants = participants
self.onItemAppear = onItemAppear
+ _selectedParticipant = selectedParticipant
}
public var body: some View {
@@ -50,6 +55,14 @@ public struct ChatInfoParticipantsView: View {
.onAppear {
onItemAppear(participant)
}
+ .contentShape(.rect)
+ .onTapGesture {
+ withAnimation {
+ if participant.id != chatClient.currentUserId {
+ selectedParticipant = participant
+ }
+ }
+ }
}
}
.background(Color(colors.background))
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ParticipantInfoView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ParticipantInfoView.swift
new file mode 100644
index 000000000..69a960893
--- /dev/null
+++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ParticipantInfoView.swift
@@ -0,0 +1,126 @@
+//
+// Copyright © 2025 Stream.io Inc. All rights reserved.
+//
+
+import SwiftUI
+
+struct ParticipantInfoView: View {
+ @Injected(\.fonts) var fonts
+ @Injected(\.colors) var colors
+
+ let participant: ParticipantInfo
+ var actions: [ParticipantAction]
+
+ var onDismiss: () -> Void
+
+ @State private var alertShown = false
+ @State private var alertAction: ParticipantAction? {
+ didSet {
+ alertShown = alertAction != nil
+ }
+ }
+
+ public var body: some View {
+ VStack {
+ Spacer()
+ VStack(spacing: 4) {
+ Text(participant.displayName)
+ .font(fonts.bodyBold)
+
+ Text(participant.onlineInfoText)
+ .font(fonts.footnote)
+ .foregroundColor(Color(colors.textLowEmphasis))
+
+ MessageAvatarView(
+ avatarURL: participant.chatUser.imageURL,
+ size: CGSize(width: 64, height: 64),
+ showOnlineIndicator: participant.chatUser.isOnline
+ )
+ .padding()
+
+ VStack {
+ ForEach(actions) { action in
+ Divider()
+ .padding(.horizontal, -16)
+
+ if let destination = action.navigationDestination {
+ NavigationLink {
+ destination
+ } label: {
+ ActionItemView(
+ title: action.title,
+ iconName: action.iconName,
+ isDestructive: action.isDestructive
+ )
+ }
+ } else {
+ Button {
+ if action.confirmationPopup != nil {
+ alertAction = action
+ } else {
+ action.action()
+ }
+ } label: {
+ ActionItemView(
+ title: action.title,
+ iconName: action.iconName,
+ isDestructive: action.isDestructive
+ )
+ }
+ }
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color(colors.background1))
+ .cornerRadius(16)
+ .padding(.all, 8)
+ .foregroundColor(Color(colors.text))
+ .opacity(alertShown ? 0 : 1)
+ }
+ .alert(isPresented: $alertShown) {
+ Alert(
+ title: Text(alertAction?.confirmationPopup?.title ?? ""),
+ message: Text(alertAction?.confirmationPopup?.message ?? ""),
+ primaryButton: .destructive(Text(alertAction?.confirmationPopup?.buttonTitle ?? "")) {
+ alertAction?.action()
+ },
+ secondaryButton: .cancel()
+ )
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .contentShape(.rect)
+ .onTapGesture {
+ onDismiss()
+ }
+ }
+}
+
+/// Model describing a participant action.
+public struct ParticipantAction: Identifiable {
+ public var id: String {
+ "\(title)-\(iconName)"
+ }
+
+ public let title: String
+ public let iconName: String
+ public let action: () -> Void
+ public let confirmationPopup: ConfirmationPopup?
+ public let isDestructive: Bool
+ public var navigationDestination: AnyView?
+
+ public init(
+ title: String,
+ iconName: String,
+ action: @escaping () -> Void,
+ confirmationPopup: ConfirmationPopup?,
+ isDestructive: Bool
+ ) {
+ self.title = title
+ self.iconName = iconName
+ self.action = action
+ self.confirmationPopup = confirmationPopup
+ self.isDestructive = isDestructive
+ }
+}
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift
index 952d0e165..988887bba 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift
@@ -91,7 +91,17 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate {
}
var firstUnreadMessageId: String? {
- controller.firstUnreadMessageId
+ if controller.firstUnreadMessageId == nil && controller.lastReadMessageId == nil {
+ let currentUserReadHasRead = controller.channel?.reads.first(where: {
+ $0.user.id == controller.client.currentUserId
+ }) != nil
+ // If the current user has unread state but no unread message is available
+ // it means the whole channel is unread, so the first message is the unread message.
+ if currentUserReadHasRead {
+ return controller.messages.last?.id
+ }
+ }
+ return controller.firstUnreadMessageId
}
init(controller: ChatChannelController) {
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift
index 9a2da41f5..8ed4ee793 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift
@@ -128,7 +128,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}
}
}
-
+
+ // A boolean value indicating if the user marked a message as unread
+ // in the current session of the channel. If it is true,
+ // it should not call markRead() in any scenario.
+ public var currentUserMarkedMessageUnread: Bool = false
+
@Published public private(set) var channel: ChatChannel?
public var isMessageThread: Bool {
@@ -347,7 +352,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
if utils.messageListConfig.dateIndicatorPlacement == .overlay {
save(lastDate: message.createdAt)
}
- if index == 0, channelDataSource.hasLoadedAllNextMessages {
+ if channelDataSource.hasLoadedAllNextMessages {
let isActive = UIApplication.shared.applicationState == .active
if isActive && canMarkRead {
sendReadEventIfNeeded(for: message)
@@ -571,7 +576,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}
private func sendReadEventIfNeeded(for message: ChatMessage) {
- guard let channel, channel.unreadCount.messages > 0 else { return }
+ guard let channel, channel.unreadCount.messages > 0 else {
+ return
+ }
+ if currentUserMarkedMessageUnread {
+ return
+ }
throttler.execute { [weak self] in
self?.channelController.markRead()
// We keep `firstUnreadMessageId` value set which keeps showing the new messages header in the channel view
@@ -679,7 +689,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
canMarkRead = true
if channel.unreadCount.messages > 0 {
- if channelController.firstUnreadMessageId != nil {
+ if channelDataSource.firstUnreadMessageId != nil {
firstUnreadMessageId = channelController.firstUnreadMessageId
canMarkRead = false
} else if channelController.lastReadMessageId != nil {
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift
index 0a2e089de..8c2afd6c2 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift
@@ -219,7 +219,7 @@ open class MessageComposerViewModel: ObservableObject {
}
private var cancellables = Set()
- private lazy var commandsHandler = utils
+ public lazy var commandsHandler = utils
.commandsConfig
.makeCommandsHandler(
with: channelController
@@ -690,7 +690,7 @@ open class MessageComposerViewModel: ObservableObject {
}
}
- private func checkForMentionedUsers(
+ public func checkForMentionedUsers(
commandId: String?,
extraData: [String: Any]
) {
@@ -701,7 +701,7 @@ open class MessageComposerViewModel: ObservableObject {
mentionedUsers.insert(user)
}
- private func clearRemovedMentions() {
+ public func clearRemovedMentions() {
for user in mentionedUsers {
if !text.contains("@\(user.mentionText)") {
mentionedUsers.remove(user)
@@ -742,7 +742,7 @@ open class MessageComposerViewModel: ObservableObject {
clearInputData()
}
- private func clearInputData() {
+ public func clearInputData() {
addedAssets = []
addedFileURLs = []
addedVoiceRecordings = []
@@ -806,7 +806,7 @@ open class MessageComposerViewModel: ObservableObject {
.store(in: &cancellables)
}
- private func checkChannelCooldown() {
+ public func checkChannelCooldown() {
let duration = channelController.channel?.cooldownDuration ?? 0
if duration > 0 && timer == nil && !isSlowModeDisabled {
cooldownDuration = duration
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ShareButtonView.swift b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ShareButtonView.swift
index d452c24c8..957089e46 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ShareButtonView.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/Gallery/ShareButtonView.swift
@@ -21,10 +21,10 @@ struct ShareButtonView: View {
.customizable()
.frame(width: 18, height: 22)
})
- .foregroundColor(Color(colors.text))
- .sheet(isPresented: $isSharePresented) {
- ShareActivityView(activityItems: content)
- }
+ .foregroundColor(Color(colors.text))
+ .sheet(isPresented: $isSharePresented) {
+ ShareActivityView(activityItems: content)
+ }
}
}
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/VoiceRecordingContainerView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/VoiceRecordingContainerView.swift
index 0516d4c55..5c4854acf 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/VoiceRecordingContainerView.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/AsyncVoiceMessages/VoiceRecordingContainerView.swift
@@ -153,8 +153,8 @@ struct VoiceRecordingView: View {
)
)
})
- .opacity(loading ? 0 : 1)
- .overlay(loading ? ProgressView() : nil)
+ .opacity(loading ? 0 : 1)
+ .overlay(loading ? ProgressView() : nil)
VStack(alignment: .leading, spacing: 4) {
Text(
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift
index 9d0919a84..7a0aa7812 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift
@@ -425,9 +425,15 @@ extension ChatMessage {
public struct MediaAttachment: Identifiable, Equatable {
@Injected(\.utils) var utils
- let url: URL
- let type: MediaAttachmentType
- var uploadingState: AttachmentUploadingState?
+ public let url: URL
+ public let type: MediaAttachmentType
+ public var uploadingState: AttachmentUploadingState?
+
+ public init(url: URL, type: MediaAttachmentType, uploadingState: AttachmentUploadingState? = nil) {
+ self.url = url
+ self.type = type
+ self.uploadingState = uploadingState
+ }
public var id: String {
url.absoluteString
@@ -477,9 +483,14 @@ extension MediaAttachment {
}
}
-enum MediaAttachmentType {
- case image
- case video
+public struct MediaAttachmentType: RawRepresentable {
+ public let rawValue: String
+ public init(rawValue: String) {
+ self.rawValue = rawValue
+ }
+
+ public static let image = Self(rawValue: "image")
+ public static let video = Self(rawValue: "video")
}
/// Options for the gallery view.
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift
index a334a3be3..47208901e 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/LinkAttachmentView.swift
@@ -14,6 +14,7 @@ public struct LinkAttachmentContainer: View {
var message: ChatMessage
var width: CGFloat
var isFirst: Bool
+ var onImageTap: ((ChatMessageLinkAttachment) -> Void)?
@Binding var scrolledId: String?
private let padding: CGFloat = 8
@@ -23,12 +24,14 @@ public struct LinkAttachmentContainer: View {
message: ChatMessage,
width: CGFloat,
isFirst: Bool,
- scrolledId: Binding
+ scrolledId: Binding,
+ onImageTap: ((ChatMessageLinkAttachment) -> Void)? = nil
) {
self.factory = factory
self.message = message
self.width = width
self.isFirst = isFirst
+ self.onImageTap = onImageTap
_scrolledId = scrolledId
}
@@ -69,7 +72,8 @@ public struct LinkAttachmentContainer: View {
LinkAttachmentView(
linkAttachment: message.linkAttachments[0],
width: width,
- isFirst: isFirst
+ isFirst: isFirst,
+ onImageTap: onImageTap
)
}
}
@@ -97,11 +101,18 @@ public struct LinkAttachmentView: View {
var linkAttachment: ChatMessageLinkAttachment
var width: CGFloat
var isFirst: Bool
-
- public init(linkAttachment: ChatMessageLinkAttachment, width: CGFloat, isFirst: Bool) {
+ var onImageTap: ((ChatMessageLinkAttachment) -> Void)?
+
+ public init(
+ linkAttachment: ChatMessageLinkAttachment,
+ width: CGFloat,
+ isFirst: Bool,
+ onImageTap: ((ChatMessageLinkAttachment) -> Void)? = nil
+ ) {
self.linkAttachment = linkAttachment
self.width = width
self.isFirst = isFirst
+ self.onImageTap = onImageTap
}
public var body: some View {
@@ -118,7 +129,7 @@ public struct LinkAttachmentView: View {
if !authorHidden {
BottomLeftView {
Text(linkAttachment.author ?? "")
- .foregroundColor(colors.tintColor)
+ .foregroundColor(colors.messageLinkAttachmentAuthorColor)
.font(fonts.bodyBold)
.standardPadding()
.bubble(
@@ -135,12 +146,14 @@ public struct LinkAttachmentView: View {
if let title = linkAttachment.title {
Text(title)
.font(fonts.footnoteBold)
+ .foregroundColor(colors.messageLinkAttachmentTitleColor)
.lineLimit(1)
}
if let description = linkAttachment.text {
Text(description)
.font(fonts.footnote)
+ .foregroundColor(colors.messageLinkAttachmentTextColor)
.lineLimit(3)
}
}
@@ -149,6 +162,10 @@ public struct LinkAttachmentView: View {
}
.padding(.horizontal, padding)
.onTapGesture {
+ if let onImageTap {
+ onImageTap(linkAttachment)
+ return
+ }
if let url = linkAttachment.originalURL.secureURL, UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:])
}
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsView.swift
index 9c49e5ecd..55c59a2f4 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsView.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollCommentsView.swift
@@ -64,15 +64,15 @@ struct PollCommentsView: View {
.bold()
.foregroundColor(colors.tintColor)
})
- .frame(maxWidth: .infinity)
- .withPollsBackground()
- .uiAlert(
- title: commentButtonTitle,
- isPresented: $viewModel.addCommentShown,
- text: $viewModel.newCommentText,
- accept: L10n.Alert.Actions.send,
- action: { viewModel.add(comment: viewModel.newCommentText) }
- )
+ .frame(maxWidth: .infinity)
+ .withPollsBackground()
+ .uiAlert(
+ title: commentButtonTitle,
+ isPresented: $viewModel.addCommentShown,
+ text: $viewModel.newCommentText,
+ accept: L10n.Alert.Actions.send,
+ action: { viewModel.add(comment: viewModel.newCommentText) }
+ )
}
}
.padding()
diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift
index 912fd0051..40f441d03 100644
--- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift
@@ -39,6 +39,8 @@ public class MessageActionsResolver: MessageActionsResolving {
}
} else if info.identifier == MessageActionId.markUnread {
viewModel.firstUnreadMessageId = info.message.messageId
+ viewModel.currentUserMarkedMessageUnread = true
+ viewModel.scrolledId = info.message.messageId
}
viewModel.reactionsShown = false
diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift
index d97b1769e..d799d56c1 100644
--- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift
@@ -212,6 +212,14 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController
}
}
}
+
+ if isSearching {
+ searchText = ""
+ }
+
+ if selectedChannel != nil {
+ selectedChannel = nil
+ }
loadUntilFound()
}
diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelSwipeableListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelSwipeableListItem.swift
index 711a0043d..511d043d5 100644
--- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelSwipeableListItem.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelSwipeableListItem.swift
@@ -287,9 +287,9 @@ public struct TrailingSwipeActionsView: View {
leftButtonTapped(channel)
}
})
- .frame(width: buttonWidth)
- .foregroundColor(Color(colors.text))
- .background(Color(colors.background1))
+ .frame(width: buttonWidth)
+ .foregroundColor(Color(colors.text))
+ .background(Color(colors.background1))
if channel.ownCapabilities.contains(.deleteChannel) {
ActionItemButton(imageName: "trash", action: {
@@ -297,9 +297,9 @@ public struct TrailingSwipeActionsView: View {
rightButtonTapped(channel)
}
})
- .frame(width: buttonWidth)
- .foregroundColor(Color(colors.textInverted))
- .background(Color(colors.alert))
+ .frame(width: buttonWidth)
+ .foregroundColor(Color(colors.textInverted))
+ .background(Color(colors.alert))
}
}
}
diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/MoreChannelActionsViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannelList/MoreChannelActionsViewModel.swift
index b24030e8e..9e69e7207 100644
--- a/Sources/StreamChatSwiftUI/ChatChannelList/MoreChannelActionsViewModel.swift
+++ b/Sources/StreamChatSwiftUI/ChatChannelList/MoreChannelActionsViewModel.swift
@@ -53,9 +53,7 @@ open class MoreChannelActionsViewModel: ObservableObject {
) {
self.channelActions = channelActions
self.channel = channel
- members = channel.lastActiveMembers.filter { [unowned self] member in
- member.id != chatClient.currentUserId
- }
+ members = channel.lastActiveMembers
}
/// Returns an image for a member.
diff --git a/Sources/StreamChatSwiftUI/ColorPalette.swift b/Sources/StreamChatSwiftUI/ColorPalette.swift
index 38c521f38..5006ef98c 100644
--- a/Sources/StreamChatSwiftUI/ColorPalette.swift
+++ b/Sources/StreamChatSwiftUI/ColorPalette.swift
@@ -8,9 +8,14 @@ import UIKit
/// Provides the colors used throughout the SDK.
public struct ColorPalette {
public init() {
+ navigationBarGlyph = .white
navigationBarTitle = text
navigationBarSubtitle = textLowEmphasis
navigationBarTintColor = tintColor
+
+ messageLinkAttachmentAuthorColor = tintColor
+ messageLinkAttachmentTitleColor = Color(text)
+ messageLinkAttachmentTextColor = Color(text)
}
/// Tint color used in UI components.
@@ -86,6 +91,12 @@ public struct ColorPalette {
public lazy var selectedReactionBackgroundColor: UIColor? = nil
public var voiceMessageControlBackground: UIColor = .streamWhiteStatic
+ // MARK: - Link Attachment View
+
+ public var messageLinkAttachmentAuthorColor: Color
+ public var messageLinkAttachmentTitleColor: Color
+ public var messageLinkAttachmentTextColor: Color
+
// MARK: - Composer
public lazy var composerPlaceholderColor: UIColor = subtitleText
@@ -94,6 +105,8 @@ public struct ColorPalette {
// MARK: - Navigation Bar
+ public var navigationBarGlyph: UIColor
+
public var navigationBarTitle: UIColor {
didSet {
let attributes: [NSAttributedString.Key: Any] = [.foregroundColor: navigationBarTitle]
diff --git a/Sources/StreamChatSwiftUI/CommonViews/NavigationBarThemeViewModifier.swift b/Sources/StreamChatSwiftUI/CommonViews/NavigationBarThemeViewModifier.swift
index e7d5b10d9..e6dd38d84 100644
--- a/Sources/StreamChatSwiftUI/CommonViews/NavigationBarThemeViewModifier.swift
+++ b/Sources/StreamChatSwiftUI/CommonViews/NavigationBarThemeViewModifier.swift
@@ -5,7 +5,7 @@
import SwiftUI
extension View {
- nonisolated public func toolbarThemed(@ToolbarContentBuilder content toolbarContent: @escaping () -> Content) -> some View where Content: ToolbarContent {
+ public nonisolated func toolbarThemed(@ToolbarContentBuilder content toolbarContent: @escaping () -> Content) -> some View where Content: ToolbarContent {
modifier(NavigationBarThemeViewModifier(toolbarContent: toolbarContent))
}
}
diff --git a/Sources/StreamChatSwiftUI/Generated/L10n.swift b/Sources/StreamChatSwiftUI/Generated/L10n.swift
index a9d6cf0bd..79a7dddf7 100644
--- a/Sources/StreamChatSwiftUI/Generated/L10n.swift
+++ b/Sources/StreamChatSwiftUI/Generated/L10n.swift
@@ -107,6 +107,16 @@ internal enum L10n {
internal static var pollYouCreated: String { L10n.tr("Localizable", "channel.item.poll-you-created") }
/// You voted:
internal static var pollYouVoted: String { L10n.tr("Localizable", "channel.item.poll-you-voted") }
+ /// Remove User
+ internal static var removeUser: String { L10n.tr("Localizable", "channel.item.remove-user") }
+ /// Are you sure you want to remove %@ from %@?
+ internal static func removeUserConfirmationMessage(_ p1: Any, _ p2: Any) -> String {
+ return L10n.tr("Localizable", "channel.item.remove-user-confirmation-message", String(describing: p1), String(describing: p2))
+ }
+ /// Remove User
+ internal static var removeUserConfirmationTitle: String { L10n.tr("Localizable", "channel.item.remove-user-confirmation-title") }
+ /// Send Direct Message
+ internal static var sendDirectMessage: String { L10n.tr("Localizable", "channel.item.send-direct-message") }
/// are typing ...
internal static var typingPlural: String { L10n.tr("Localizable", "channel.item.typing-plural") }
/// is typing ...
diff --git a/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift b/Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift
index a84b5e4ca..b97f1f3b2 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 = "4.89.1"
+ public static let version: String = "4.90.0"
}
diff --git a/Sources/StreamChatSwiftUI/Info.plist b/Sources/StreamChatSwiftUI/Info.plist
index 9e228f9cf..eca9a0a57 100644
--- a/Sources/StreamChatSwiftUI/Info.plist
+++ b/Sources/StreamChatSwiftUI/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 4.89.1
+ 4.90.0
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
NSPhotoLibraryUsageDescription
diff --git a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings
index 0257af1a7..7ac2913f6 100644
--- a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings
+++ b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings
@@ -223,6 +223,10 @@
"channel.item.video" = "Video";
"channel.item.poll" = "Poll";
"channel.item.voice-message" = "Voice Message";
+"channel.item.remove-user" = "Remove User";
+"channel.item.remove-user-confirmation-title" = "Remove User";
+"channel.item.remove-user-confirmation-message" = "Are you sure you want to remove %@ from %@?";
+"channel.item.send-direct-message" = "Send Direct Message";
// - MARK: Threads
diff --git a/Sources/StreamChatSwiftUI/Utils.swift b/Sources/StreamChatSwiftUI/Utils.swift
index d29f913ae..35e2c9423 100644
--- a/Sources/StreamChatSwiftUI/Utils.swift
+++ b/Sources/StreamChatSwiftUI/Utils.swift
@@ -8,7 +8,7 @@ import StreamChat
/// Class providing implementations of several utilities used in the SDK.
/// The default implementations can be replaced in the init method, or directly via the variables.
public class Utils {
- var markdownFormatter = MarkdownFormatter()
+ public var markdownFormatter: MarkdownFormatter
public var dateFormatter: DateFormatter
@@ -76,6 +76,7 @@ public class Utils {
internal var pollsDateFormatter = PollsDateFormatter()
public init(
+ markdownFormatter: MarkdownFormatter = DefaultMarkdownFormatter(),
dateFormatter: DateFormatter = .makeDefault(),
messageRelativeDateFormatter: DateFormatter = MessageRelativeDateFormatter(),
galleryHeaderViewDateFormatter: DateFormatter = GalleryHeaderViewDateFormatter(),
@@ -104,6 +105,7 @@ public class Utils {
sortReactions: @escaping (MessageReactionType, MessageReactionType) -> Bool = Utils.defaultSortReactions,
shouldSyncChannelControllerOnAppear: @escaping (ChatChannelController) -> Bool = { _ in true }
) {
+ self.markdownFormatter = markdownFormatter
self.dateFormatter = dateFormatter
self.messageRelativeDateFormatter = messageRelativeDateFormatter
self.galleryHeaderViewDateFormatter = galleryHeaderViewDateFormatter
diff --git a/Sources/StreamChatSwiftUI/Utils/Common/InputTextView.swift b/Sources/StreamChatSwiftUI/Utils/Common/InputTextView.swift
index daa44079d..934534ab3 100644
--- a/Sources/StreamChatSwiftUI/Utils/Common/InputTextView.swift
+++ b/Sources/StreamChatSwiftUI/Utils/Common/InputTextView.swift
@@ -4,7 +4,7 @@
import UIKit
-struct TextSizeConstants {
+enum TextSizeConstants {
static let composerConfig = InjectedValues[\.utils].composerConfig
static let defaultInputViewHeight: CGFloat = 38.0
static var minimumHeight: CGFloat {
diff --git a/Sources/StreamChatSwiftUI/Utils/Common/NukeImageProcessor.swift b/Sources/StreamChatSwiftUI/Utils/Common/NukeImageProcessor.swift
index f4e2964a4..63143ae46 100644
--- a/Sources/StreamChatSwiftUI/Utils/Common/NukeImageProcessor.swift
+++ b/Sources/StreamChatSwiftUI/Utils/Common/NukeImageProcessor.swift
@@ -69,11 +69,11 @@ extension ImageProcessors {
return size
}
- private let sizeProvider: @Sendable() -> CGSize
+ private let sizeProvider: @Sendable () -> CGSize
/// Initializes the processor with size providing closure.
/// - Parameter sizeProvider: Closure to obtain size after the image is loaded.
- public init(sizeProvider: @escaping @Sendable() -> CGSize) {
+ public init(sizeProvider: @escaping @Sendable () -> CGSize) {
self.sizeProvider = sizeProvider
}
diff --git a/Sources/StreamChatSwiftUI/Utils/Common/UIFont+Extensions.swift b/Sources/StreamChatSwiftUI/Utils/Common/UIFont+Extensions.swift
index 926dd2ba5..901ea81a4 100644
--- a/Sources/StreamChatSwiftUI/Utils/Common/UIFont+Extensions.swift
+++ b/Sources/StreamChatSwiftUI/Utils/Common/UIFont+Extensions.swift
@@ -6,8 +6,10 @@ import UIKit
extension UIFont {
func withTraits(traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
- let descriptor = fontDescriptor.withSymbolicTraits(traits)
- return UIFont(descriptor: descriptor!, size: pointSize)
+ guard let descriptor = fontDescriptor.withSymbolicTraits(traits) else {
+ return self
+ }
+ return UIFont(descriptor: descriptor, size: pointSize)
}
var bold: UIFont {
diff --git a/Sources/StreamChatSwiftUI/Utils/Common/VideoPreviewLoader.swift b/Sources/StreamChatSwiftUI/Utils/Common/VideoPreviewLoader.swift
index 7567088d4..0dc842e57 100644
--- a/Sources/StreamChatSwiftUI/Utils/Common/VideoPreviewLoader.swift
+++ b/Sources/StreamChatSwiftUI/Utils/Common/VideoPreviewLoader.swift
@@ -42,7 +42,6 @@ public final class DefaultVideoPreviewLoader: VideoPreviewLoader {
}
utils.fileCDN.adjustedURL(for: url) { result in
-
let adjustedUrl: URL
switch result {
case let .success(url):
diff --git a/Sources/StreamChatSwiftUI/Utils/MarkdownFormatter.swift b/Sources/StreamChatSwiftUI/Utils/MarkdownFormatter.swift
index 97faebdca..31a16db6b 100644
--- a/Sources/StreamChatSwiftUI/Utils/MarkdownFormatter.swift
+++ b/Sources/StreamChatSwiftUI/Utils/MarkdownFormatter.swift
@@ -6,15 +6,34 @@ import Foundation
import StreamChat
import SwiftUI
+public protocol MarkdownFormatter {
+ /// Formats a Markdown string into an `AttributedString`, merging Markdown styles with the provided base attributes and honoring the given layout direction.
+ /// - Parameters:
+ /// - string: The Markdown-formatted source string to render.
+ /// - attributes: Base attributes applied to the entire string; Markdown-specific styling is merged on top of these defaults.
+ /// - layoutDirection: The text layout direction (left-to-right or right-to-left) used when interpreting and rendering Markdown blocks (for example, lists, block quotes, and headings).
+ /// - Returns: An `AttributedString` containing the rendered Markdown with the resolved attributes.
+ @available(iOS 15, *)
+ func format(
+ _ string: String,
+ attributes: AttributeContainer,
+ layoutDirection: LayoutDirection
+ ) -> AttributedString
+}
+
/// Converts markdown string to AttributedString with styling attributes.
-final class MarkdownFormatter {
+open class DefaultMarkdownFormatter: MarkdownFormatter {
@Injected(\.colors) private var colors
@Injected(\.fonts) private var fonts
- private let markdownParser = MarkdownParser()
+ private let markdownParser: MarkdownParser
+
+ public init() {
+ markdownParser = MarkdownParser()
+ }
@available(iOS 15, *)
- func format(
+ open func format(
_ string: String,
attributes: AttributeContainer,
layoutDirection: LayoutDirection
diff --git a/StreamChatSwiftUI-XCFramework.podspec b/StreamChatSwiftUI-XCFramework.podspec
index ca1054ff1..75dfae9b1 100644
--- a/StreamChatSwiftUI-XCFramework.podspec
+++ b/StreamChatSwiftUI-XCFramework.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = 'StreamChatSwiftUI-XCFramework'
- spec.version = '4.89.1'
+ spec.version = '4.90.0'
spec.summary = 'StreamChat SwiftUI Chat Components'
spec.description = 'StreamChatSwiftUI SDK offers flexible SwiftUI components able to display data provided by StreamChat SDK.'
@@ -19,7 +19,7 @@ Pod::Spec.new do |spec|
spec.framework = 'Foundation', 'UIKit', 'SwiftUI'
- spec.dependency 'StreamChat-XCFramework', '~> 4.89.0'
+ spec.dependency 'StreamChat-XCFramework', '~> 4.90.0'
spec.cocoapods_version = '>= 1.11.0'
end
diff --git a/StreamChatSwiftUI.podspec b/StreamChatSwiftUI.podspec
index 67497f9d3..0d1ce08e2 100644
--- a/StreamChatSwiftUI.podspec
+++ b/StreamChatSwiftUI.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = 'StreamChatSwiftUI'
- spec.version = '4.89.1'
+ spec.version = '4.90.0'
spec.summary = 'StreamChat SwiftUI Chat Components'
spec.description = 'StreamChatSwiftUI SDK offers flexible SwiftUI components able to display data provided by StreamChat SDK.'
@@ -19,5 +19,5 @@ Pod::Spec.new do |spec|
spec.framework = 'Foundation', 'UIKit', 'SwiftUI'
- spec.dependency 'StreamChat', '~> 4.89.0'
+ spec.dependency 'StreamChat', '~> 4.90.0'
end
diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj
index f84a29315..a87a019fb 100644
--- a/StreamChatSwiftUI.xcodeproj/project.pbxproj
+++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj
@@ -489,6 +489,7 @@
84EADEC12B2AFA690046B50C /* MessageComposerViewModel+Recording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EADEC02B2AFA690046B50C /* MessageComposerViewModel+Recording.swift */; };
84EADEC32B2B24E60046B50C /* AudioSessionFeedbackGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EADEC22B2B24E60046B50C /* AudioSessionFeedbackGenerator.swift */; };
84EADEC52B2C4A5B0046B50C /* AddedVoiceRecordingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EADEC42B2C4A5B0046B50C /* AddedVoiceRecordingsView.swift */; };
+ 84EB881A2E8ABA610076DC17 /* ParticipantInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EB88192E8ABA610076DC17 /* ParticipantInfoView.swift */; };
84EDBC37274FE5CD0057218D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 84EDBC36274FE5CD0057218D /* Localizable.strings */; };
84F130C12AEAA957006E7B52 /* StreamLazyImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F130C02AEAA957006E7B52 /* StreamLazyImage.swift */; };
84F2908A276B90610045472D /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F29089276B90610045472D /* GalleryView.swift */; };
@@ -527,8 +528,8 @@
AD3AB65C2CB730090014D4D7 /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB65B2CB730090014D4D7 /* Shimmer.swift */; };
AD3AB65E2CB731360014D4D7 /* ChatThreadListLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB65D2CB731360014D4D7 /* ChatThreadListLoadingView.swift */; };
AD3AB6602CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB65F2CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift */; };
- AD3DB8342E7C7BD50023D377 /* MessageAttachmentsConverter_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3DB8332E7C7BD50023D377 /* MessageAttachmentsConverter_Tests.swift */; };
AD3DB82F2E7C2E190023D377 /* GalleryHeaderViewDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3DB82E2E7C2E190023D377 /* GalleryHeaderViewDateFormatter.swift */; };
+ AD3DB8342E7C7BD50023D377 /* MessageAttachmentsConverter_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3DB8332E7C7BD50023D377 /* MessageAttachmentsConverter_Tests.swift */; };
AD51D9182DB9543A0068D0B0 /* MessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD51D9172DB9543A0068D0B0 /* MessageViewModel.swift */; };
AD5C0A5F2D6FDD9700E1E500 /* BouncedMessageActionsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5C0A5E2D6FDD8600E1E500 /* BouncedMessageActionsModifier.swift */; };
AD6B7E052D356E8800ADEF39 /* ReactionsUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6B7E042D356E8800ADEF39 /* ReactionsUsersViewModel.swift */; };
@@ -1104,6 +1105,7 @@
84EADEC02B2AFA690046B50C /* MessageComposerViewModel+Recording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageComposerViewModel+Recording.swift"; sourceTree = ""; };
84EADEC22B2B24E60046B50C /* AudioSessionFeedbackGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionFeedbackGenerator.swift; sourceTree = ""; };
84EADEC42B2C4A5B0046B50C /* AddedVoiceRecordingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddedVoiceRecordingsView.swift; sourceTree = ""; };
+ 84EB88192E8ABA610076DC17 /* ParticipantInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantInfoView.swift; sourceTree = ""; };
84EDBC36274FE5CD0057218D /* Localizable.strings */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; };
84F130C02AEAA957006E7B52 /* StreamLazyImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamLazyImage.swift; sourceTree = ""; };
84F29089276B90610045472D /* GalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryView.swift; sourceTree = ""; };
@@ -1142,8 +1144,8 @@
AD3AB65B2CB730090014D4D7 /* Shimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmer.swift; sourceTree = ""; };
AD3AB65D2CB731360014D4D7 /* ChatThreadListLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListLoadingView.swift; sourceTree = ""; };
AD3AB65F2CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListHeaderViewModifier.swift; sourceTree = ""; };
- AD3DB8332E7C7BD50023D377 /* MessageAttachmentsConverter_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageAttachmentsConverter_Tests.swift; sourceTree = ""; };
AD3DB82E2E7C2E190023D377 /* GalleryHeaderViewDateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryHeaderViewDateFormatter.swift; sourceTree = ""; };
+ AD3DB8332E7C7BD50023D377 /* MessageAttachmentsConverter_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageAttachmentsConverter_Tests.swift; sourceTree = ""; };
AD51D9172DB9543A0068D0B0 /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = ""; };
AD5C0A5E2D6FDD8600E1E500 /* BouncedMessageActionsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BouncedMessageActionsModifier.swift; sourceTree = ""; };
AD6B7E042D356E8800ADEF39 /* ReactionsUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsUsersViewModel.swift; sourceTree = ""; };
@@ -1597,6 +1599,7 @@
84A1CACE2816BCF00046595A /* AddUsersView.swift */,
84A1CAD02816C6900046595A /* AddUsersViewModel.swift */,
849FD5102811B05C00952934 /* ChatInfoParticipantsView.swift */,
+ 84EB88192E8ABA610076DC17 /* ParticipantInfoView.swift */,
84289BE4280720E700282ABE /* PinnedMessagesView.swift */,
84289BE62807214200282ABE /* PinnedMessagesViewModel.swift */,
84289BE82807238C00282ABE /* MediaAttachmentsView.swift */,
@@ -2742,6 +2745,7 @@
ADE0F5662CB962470053B8B9 /* ActionBannerView.swift in Sources */,
84BB4C4C2841104700CBE004 /* MessageListDateUtils.swift in Sources */,
82D64BE72AD7E5B700C5C79E /* ImageTask.swift in Sources */,
+ 84EB881A2E8ABA610076DC17 /* ParticipantInfoView.swift in Sources */,
8465FD742746A95700AF091E /* ViewFactory.swift in Sources */,
8465FDC12746A95700AF091E /* NoChannelsView.swift in Sources */,
82D64BDC2AD7E5B700C5C79E /* ImageSourceHelpers.swift in Sources */,
@@ -3936,7 +3940,7 @@
repositoryURL = "https://github.com/GetStream/stream-chat-swift.git";
requirement = {
kind = upToNextMajorVersion;
- minimumVersion = 4.89.0;
+ minimumVersion = 4.90.0;
};
};
E3A1C01A282BAC66002D1E26 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
diff --git a/StreamChatSwiftUIArtifacts.json b/StreamChatSwiftUIArtifacts.json
index 352840374..212e8b2ec 100644
--- a/StreamChatSwiftUIArtifacts.json
+++ b/StreamChatSwiftUIArtifacts.json
@@ -1 +1 @@
-{"4.40.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.40.0/StreamChatSwiftUI.zip","4.41.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.41.0/StreamChatSwiftUI.zip","4.42.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.42.0/StreamChatSwiftUI.zip","4.43.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.43.0/StreamChatSwiftUI.zip","4.44.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.44.0/StreamChatSwiftUI.zip","4.45.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.45.0/StreamChatSwiftUI.zip","4.46.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.46.0/StreamChatSwiftUI.zip","4.47.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.47.0/StreamChatSwiftUI.zip","4.47.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.47.1/StreamChatSwiftUI.zip","4.48.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.48.0/StreamChatSwiftUI.zip","4.49.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.49.0/StreamChatSwiftUI.zip","4.50.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.50.0/StreamChatSwiftUI.zip","4.50.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.50.1/StreamChatSwiftUI.zip","4.51.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.51.0/StreamChatSwiftUI.zip","4.52.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.52.0/StreamChatSwiftUI.zip","4.53.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.53.0/StreamChatSwiftUI.zip","4.54.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.54.0/StreamChatSwiftUI.zip","4.55.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.55.0/StreamChatSwiftUI.zip","4.56.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.56.0/StreamChatSwiftUI.zip","4.57.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.57.0/StreamChatSwiftUI.zip","4.58.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.58.0/StreamChatSwiftUI.zip","4.59.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.59.0/StreamChatSwiftUI.zip","4.60.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.60.0/StreamChatSwiftUI.zip","4.61.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.61.0/StreamChatSwiftUI.zip","4.62.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.62.0/StreamChatSwiftUI.zip","4.63.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.63.0/StreamChatSwiftUI.zip","4.64.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.64.0/StreamChatSwiftUI.zip","4.65.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.65.0/StreamChatSwiftUI.zip","4.66.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.66.0/StreamChatSwiftUI.zip","4.67.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.67.0/StreamChatSwiftUI.zip","4.68.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.68.0/StreamChatSwiftUI.zip","4.69.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.69.0/StreamChatSwiftUI.zip","4.70.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.70.0/StreamChatSwiftUI.zip","4.71.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.71.0/StreamChatSwiftUI.zip","4.72.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.72.0/StreamChatSwiftUI.zip","4.73.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.73.0/StreamChatSwiftUI.zip","4.74.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.74.0/StreamChatSwiftUI.zip","4.75.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.75.0/StreamChatSwiftUI.zip","4.76.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.76.0/StreamChatSwiftUI.zip","4.77.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.77.0/StreamChatSwiftUI.zip","4.78.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.78.0/StreamChatSwiftUI.zip","4.79.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.79.0/StreamChatSwiftUI.zip","4.79.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.79.1/StreamChatSwiftUI.zip","4.80.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.80.0/StreamChatSwiftUI.zip","4.81.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.81.0/StreamChatSwiftUI.zip","4.82.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.82.0/StreamChatSwiftUI.zip","4.83.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.83.0/StreamChatSwiftUI.zip","4.84.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.84.0/StreamChatSwiftUI.zip","4.85.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.85.0/StreamChatSwiftUI.zip","4.86.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.86.0/StreamChatSwiftUI.zip","4.87.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.87.0/StreamChatSwiftUI.zip","4.88.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.88.0/StreamChatSwiftUI.zip","4.89.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.89.0/StreamChatSwiftUI.zip","4.89.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.89.1/StreamChatSwiftUI.zip"}
\ No newline at end of file
+{"4.40.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.40.0/StreamChatSwiftUI.zip","4.41.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.41.0/StreamChatSwiftUI.zip","4.42.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.42.0/StreamChatSwiftUI.zip","4.43.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.43.0/StreamChatSwiftUI.zip","4.44.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.44.0/StreamChatSwiftUI.zip","4.45.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.45.0/StreamChatSwiftUI.zip","4.46.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.46.0/StreamChatSwiftUI.zip","4.47.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.47.0/StreamChatSwiftUI.zip","4.47.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.47.1/StreamChatSwiftUI.zip","4.48.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.48.0/StreamChatSwiftUI.zip","4.49.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.49.0/StreamChatSwiftUI.zip","4.50.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.50.0/StreamChatSwiftUI.zip","4.50.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.50.1/StreamChatSwiftUI.zip","4.51.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.51.0/StreamChatSwiftUI.zip","4.52.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.52.0/StreamChatSwiftUI.zip","4.53.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.53.0/StreamChatSwiftUI.zip","4.54.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.54.0/StreamChatSwiftUI.zip","4.55.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.55.0/StreamChatSwiftUI.zip","4.56.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.56.0/StreamChatSwiftUI.zip","4.57.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.57.0/StreamChatSwiftUI.zip","4.58.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.58.0/StreamChatSwiftUI.zip","4.59.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.59.0/StreamChatSwiftUI.zip","4.60.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.60.0/StreamChatSwiftUI.zip","4.61.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.61.0/StreamChatSwiftUI.zip","4.62.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.62.0/StreamChatSwiftUI.zip","4.63.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.63.0/StreamChatSwiftUI.zip","4.64.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.64.0/StreamChatSwiftUI.zip","4.65.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.65.0/StreamChatSwiftUI.zip","4.66.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.66.0/StreamChatSwiftUI.zip","4.67.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.67.0/StreamChatSwiftUI.zip","4.68.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.68.0/StreamChatSwiftUI.zip","4.69.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.69.0/StreamChatSwiftUI.zip","4.70.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.70.0/StreamChatSwiftUI.zip","4.71.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.71.0/StreamChatSwiftUI.zip","4.72.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.72.0/StreamChatSwiftUI.zip","4.73.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.73.0/StreamChatSwiftUI.zip","4.74.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.74.0/StreamChatSwiftUI.zip","4.75.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.75.0/StreamChatSwiftUI.zip","4.76.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.76.0/StreamChatSwiftUI.zip","4.77.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.77.0/StreamChatSwiftUI.zip","4.78.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.78.0/StreamChatSwiftUI.zip","4.79.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.79.0/StreamChatSwiftUI.zip","4.79.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.79.1/StreamChatSwiftUI.zip","4.80.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.80.0/StreamChatSwiftUI.zip","4.81.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.81.0/StreamChatSwiftUI.zip","4.82.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.82.0/StreamChatSwiftUI.zip","4.83.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.83.0/StreamChatSwiftUI.zip","4.84.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.84.0/StreamChatSwiftUI.zip","4.85.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.85.0/StreamChatSwiftUI.zip","4.86.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.86.0/StreamChatSwiftUI.zip","4.87.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.87.0/StreamChatSwiftUI.zip","4.88.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.88.0/StreamChatSwiftUI.zip","4.89.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.89.0/StreamChatSwiftUI.zip","4.89.1":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.89.1/StreamChatSwiftUI.zip","4.90.0":"https://github.com/GetStream/stream-chat-swiftui/releases/download/4.90.0/StreamChatSwiftUI.zip"}
\ No newline at end of file
diff --git a/StreamChatSwiftUITests/Infrastructure/Shared/CustomAssertions/AssertAsync.swift b/StreamChatSwiftUITests/Infrastructure/Shared/CustomAssertions/AssertAsync.swift
index 4703c5bd6..7623a3c47 100644
--- a/StreamChatSwiftUITests/Infrastructure/Shared/CustomAssertions/AssertAsync.swift
+++ b/StreamChatSwiftUITests/Infrastructure/Shared/CustomAssertions/AssertAsync.swift
@@ -367,7 +367,6 @@ extension AssertAsync {
) {
_ = withoutActuallyEscaping(expression) { expression in
withoutActuallyEscaping(message) { message in
-
AssertAsync {
Assert.willBeEqual(
expression(),
@@ -401,7 +400,6 @@ extension AssertAsync {
) {
_ = withoutActuallyEscaping(expression) { expression in
withoutActuallyEscaping(message) { message in
-
AssertAsync {
Assert.willBeEqual(
expression(),
@@ -438,7 +436,6 @@ extension AssertAsync {
_ = withoutActuallyEscaping(expression1) { expression1 in
withoutActuallyEscaping(expression2) { expression2 in
withoutActuallyEscaping(message) { message in
-
AssertAsync {
Assert.willBeEqual(
expression1(),
@@ -473,7 +470,6 @@ extension AssertAsync {
) {
_ = withoutActuallyEscaping(expression) { expression in
withoutActuallyEscaping(message) { message in
-
AssertAsync {
Assert.willBeTrue(
expression() == nil,
@@ -535,7 +531,6 @@ extension AssertAsync {
_ = withoutActuallyEscaping(expression1) { expression1 in
withoutActuallyEscaping(expression2) { expression2 in
withoutActuallyEscaping(message) { message in
-
AssertAsync {
Assert.staysEqual(
expression1(),
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChannelInfoMockUtils.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChannelInfoMockUtils.swift
index 8fc866f4d..f6eaf2466 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChannelInfoMockUtils.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChannelInfoMockUtils.swift
@@ -5,7 +5,7 @@
@testable import StreamChat
@testable import StreamChatSwiftUI
-struct ChannelInfoMockUtils {
+enum ChannelInfoMockUtils {
static func setupMockMembers(
count: Int,
currentUserId: String,
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoViewModel_Tests.swift
index 802aaa156..de6a0d03a 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoViewModel_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoViewModel_Tests.swift
@@ -4,6 +4,7 @@
@testable import StreamChat
@testable import StreamChatSwiftUI
+@testable import StreamChatTestTools
import XCTest
class ChatChannelInfoViewModel_Tests: StreamChatTestCase {
@@ -297,9 +298,344 @@ class ChatChannelInfoViewModel_Tests: StreamChatTestCase {
XCTAssert(leaveButton == false)
}
+ func test_chatChannelInfoVM_participantActions_withMutesEnabled() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let actions = viewModel.participantActions(for: participant)
+
+ // Then
+ XCTAssert(actions.count == 4) // mute, remove, cancel
+ XCTAssert(actions.contains { $0.title.contains("Mute") })
+ XCTAssert(actions.contains { $0.title.contains("Remove") })
+ XCTAssert(actions.contains { $0.title == L10n.Alert.Actions.cancel })
+ }
+
+ func test_chatChannelInfoVM_participantActions_withMutesDisabled() {
+ // Given
+ let channel = mockGroup(with: 5, updateCapabilities: true, mutesEnabled: false)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let actions = viewModel.participantActions(for: participant)
+
+ // Then
+ XCTAssert(actions.count == 3) // direct message, remove, cancel
+ XCTAssertNotNil(actions.first?.navigationDestination)
+ XCTAssertFalse(actions.contains { $0.title.contains("Mute") })
+ XCTAssert(actions.contains { $0.title.contains("Remove") })
+ XCTAssert(actions.contains { $0.title == L10n.Alert.Actions.cancel })
+ }
+
+ func test_chatChannelInfoVM_participantActions_withoutUpdateMembersCapability() {
+ // Given
+ let channel = mockGroup(with: 5, updateCapabilities: false)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let actions = viewModel.participantActions(for: participant)
+
+ // Then
+ XCTAssert(actions.count == 3) // direct message, mute, cancel (no remove)
+ XCTAssert(actions.contains { $0.title.contains("Mute") })
+ XCTAssertFalse(actions.contains { $0.title.contains("Remove") })
+ XCTAssert(actions.contains { $0.title == L10n.Alert.Actions.cancel })
+ }
+
+ func test_chatChannelInfoVM_participantActions_withMutedUser() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let mutedUser = ChatUser.mock(id: .unique)
+ let participant = ParticipantInfo(
+ chatUser: mutedUser,
+ displayName: "Muted User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let actions = viewModel.participantActions(for: participant)
+
+ // Then
+ XCTAssert(actions.count >= 2) // At least remove and cancel
+ XCTAssert(actions.contains { $0.title.contains("Remove") })
+ XCTAssert(actions.contains { $0.title == L10n.Alert.Actions.cancel })
+ }
+
+ func test_chatChannelInfoVM_participantActions_withUnmutedUser() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let mutedUser = ChatUser.mock(id: .unique)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let currentUserController = CurrentChatUserController_Mock(client: chatClient)
+ let currentUser = CurrentChatUser.mock(id: .unique, mutedUsers: [mutedUser])
+ currentUserController.currentUser_mock = currentUser
+ viewModel.currentUserController = currentUserController
+
+ let participant = ParticipantInfo(
+ chatUser: mutedUser,
+ displayName: "Unmute User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let actions = viewModel.participantActions(for: participant)
+
+ // Then
+ XCTAssert(actions.count >= 2) // At least remove and cancel
+ XCTAssert(actions.contains { $0.title.contains("Remove") })
+ XCTAssert(actions.contains { $0.title == L10n.Alert.Actions.cancel })
+ }
+
+ func test_chatChannelInfoVM_muteAction_properties() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let muteAction = viewModel.muteAction(
+ participant: participant,
+ onDismiss: {},
+ onError: { _ in }
+ )
+
+ // Then
+ XCTAssert(muteAction.title.contains("Mute"))
+ XCTAssert(muteAction.title.contains("Test User"))
+ XCTAssert(muteAction.iconName == "speaker.slash")
+ XCTAssert(muteAction.isDestructive == false)
+ XCTAssertNotNil(muteAction.confirmationPopup)
+ XCTAssert(muteAction.confirmationPopup?.title.contains("Mute") == true)
+ XCTAssert(muteAction.confirmationPopup?.title.contains("Test User") == true)
+ XCTAssert(muteAction.confirmationPopup?.buttonTitle.contains("Mute") == true)
+ }
+
+ func test_chatChannelInfoVM_unmuteAction_properties() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let unmuteAction = viewModel.unmuteAction(
+ participant: participant,
+ onDismiss: {},
+ onError: { _ in }
+ )
+
+ // Then
+ XCTAssert(unmuteAction.title.contains("Unmute"))
+ XCTAssert(unmuteAction.title.contains("Test User"))
+ XCTAssert(unmuteAction.iconName == "speaker.wave.1")
+ XCTAssert(unmuteAction.isDestructive == false)
+ XCTAssertNotNil(unmuteAction.confirmationPopup)
+ XCTAssert(unmuteAction.confirmationPopup?.title.contains("Unmute") == true)
+ XCTAssert(unmuteAction.confirmationPopup?.title.contains("Test User") == true)
+ XCTAssert(unmuteAction.confirmationPopup?.buttonTitle.contains("Unmute") == true)
+ }
+
+ func test_chatChannelInfoVM_removeUserAction_properties() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let removeAction = viewModel.removeUserAction(
+ participant: participant,
+ onDismiss: {},
+ onError: { _ in }
+ )
+
+ // Then
+ XCTAssert(removeAction.title == L10n.Channel.Item.removeUser)
+ XCTAssert(removeAction.iconName == "person.slash")
+ XCTAssert(removeAction.isDestructive == true)
+ XCTAssertNotNil(removeAction.confirmationPopup)
+ XCTAssert(removeAction.confirmationPopup?.title == L10n.Channel.Item.removeUserConfirmationTitle)
+ XCTAssert(removeAction.confirmationPopup?.buttonTitle == L10n.Channel.Item.removeUser)
+ }
+
+ func test_chatChannelInfoVM_muteAction_execution() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let muteAction = viewModel.muteAction(
+ participant: participant,
+ onDismiss: {},
+ onError: { _ in }
+ )
+
+ muteAction.action()
+
+ // Then
+ XCTAssertNotNil(muteAction.action)
+ }
+
+ func test_chatChannelInfoVM_unmuteAction_execution() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let unmuteAction = viewModel.unmuteAction(
+ participant: participant,
+ onDismiss: {},
+ onError: { _ in }
+ )
+
+ unmuteAction.action()
+
+ // Then
+ XCTAssertNotNil(unmuteAction.action)
+ }
+
+ func test_chatChannelInfoVM_removeUserAction_execution() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ // When
+ let removeAction = viewModel.removeUserAction(
+ participant: participant,
+ onDismiss: {},
+ onError: { _ in }
+ )
+
+ removeAction.action()
+
+ // Then
+ XCTAssertNotNil(removeAction.action)
+ }
+
+ func test_chatChannelInfoVM_participantActions_cancelAction() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let participant = ParticipantInfo(
+ chatUser: ChatUser.mock(id: .unique),
+ displayName: "Test User",
+ onlineInfoText: "online",
+ isDeactivated: false
+ )
+
+ viewModel.selectedParticipant = participant
+
+ // When
+ let actions = viewModel.participantActions(for: participant)
+ let cancelAction = actions.first { $0.title == L10n.Alert.Actions.cancel }
+
+ cancelAction?.action()
+
+ // Then
+ XCTAssertNil(viewModel.selectedParticipant)
+ }
+
+ func test_chatChannelInfoVM_channelUpdated_updatesParticipants() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+ let controller = ChatChannelController_Mock.mock()
+ viewModel.channelController = controller
+ controller.delegate = viewModel
+
+ // When
+ let updated = mockGroup(with: 6)
+ controller.simulate(channel: updated, change: .update(updated), typingUsers: [])
+
+ // Then
+ XCTAssertEqual(viewModel.participants.count, 6)
+ }
+
+ func test_chatChannelInfoVM_handleParticipantActionDismiss() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+
+ // When
+ viewModel.handleParticipantActionDismiss()
+
+ // Then
+ XCTAssertNil(viewModel.selectedParticipant)
+ }
+
+ func test_chatChannelInfoVM_handleParticipantActionError() {
+ // Given
+ let channel = mockGroup(with: 5)
+ let viewModel = ChatChannelInfoViewModel(channel: channel)
+
+ // When
+ viewModel.handleParticipantActionError(ClientError.Unknown())
+
+ // Then
+ XCTAssertEqual(viewModel.errorShown, true)
+ }
+
// MARK: - private
- private func mockGroup(with memberCount: Int, updateCapabilities: Bool = true) -> ChatChannel {
+ private func mockGroup(
+ with memberCount: Int,
+ updateCapabilities: Bool = true,
+ mutesEnabled: Bool = true
+ ) -> ChatChannel {
let cid: ChannelId = .unique
let activeMembers = ChannelInfoMockUtils.setupMockMembers(
count: memberCount,
@@ -312,8 +648,12 @@ class ChatChannelInfoViewModel_Tests: StreamChatTestCase {
capabilities.insert(.leaveChannel)
capabilities.insert(.updateChannelMembers)
}
+
+ let channelConfig = ChannelConfig(mutesEnabled: mutesEnabled)
+
let channel = ChatChannel.mock(
cid: cid,
+ config: channelConfig,
ownCapabilities: capabilities,
lastActiveMembers: activeMembers,
memberCount: activeMembers.count
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoView_Tests.swift
index fb0a14748..c6c7d4d14 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoView_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoView_Tests.swift
@@ -311,4 +311,110 @@ class ChatChannelInfoView_Tests: StreamChatTestCase {
// Then
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
}
+
+ func test_chatChannelInfoView_participantSelectedBasicActionsSnapshot() {
+ // Given
+ let members = ChannelInfoMockUtils.setupMockMembers(
+ count: 4,
+ currentUserId: chatClient.currentUserId!,
+ onlineUserIndexes: [0, 1]
+ )
+ let group = ChatChannel.mock(
+ cid: .unique,
+ name: "Test Group",
+ ownCapabilities: [.updateChannelMembers],
+ lastActiveMembers: members,
+ memberCount: members.count
+ )
+ let viewModel = ChatChannelInfoViewModel(channel: group)
+ // Select the second participant (index 1)
+ viewModel.selectedParticipant = viewModel.displayedParticipants[1]
+
+ // When
+ let view = ChatChannelInfoView(viewModel: viewModel)
+ .applyDefaultSize()
+
+ // Then
+ assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
+ }
+
+ func test_chatChannelInfoView_participantSelectedWithMuteActionsSnapshot() {
+ // Given
+ let members = ChannelInfoMockUtils.setupMockMembers(
+ count: 4,
+ currentUserId: chatClient.currentUserId!,
+ onlineUserIndexes: [0, 1]
+ )
+ let config = ChannelConfig(mutesEnabled: true)
+ let group = ChatChannel.mock(
+ cid: .unique,
+ name: "Test Group",
+ config: config,
+ ownCapabilities: [.updateChannelMembers],
+ lastActiveMembers: members,
+ memberCount: members.count
+ )
+ let viewModel = ChatChannelInfoViewModel(channel: group)
+ // Select the second participant (index 1)
+ viewModel.selectedParticipant = viewModel.displayedParticipants[1]
+
+ // When
+ let view = ChatChannelInfoView(viewModel: viewModel)
+ .applyDefaultSize()
+
+ // Then
+ assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
+ }
+
+ func test_chatChannelInfoView_participantSelectedWithRemoveActionSnapshot() {
+ // Given
+ let members = ChannelInfoMockUtils.setupMockMembers(
+ count: 4,
+ currentUserId: chatClient.currentUserId!,
+ onlineUserIndexes: [0, 1]
+ )
+ let group = ChatChannel.mock(
+ cid: .unique,
+ name: "Test Group",
+ ownCapabilities: [.updateChannelMembers],
+ lastActiveMembers: members,
+ memberCount: members.count
+ )
+ let viewModel = ChatChannelInfoViewModel(channel: group)
+ // Select the second participant (index 1)
+ viewModel.selectedParticipant = viewModel.displayedParticipants[1]
+
+ // When
+ let view = ChatChannelInfoView(viewModel: viewModel)
+ .applyDefaultSize()
+
+ // Then
+ assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
+ }
+
+ func test_chatChannelInfoView_participantSelectedOfflineUserSnapshot() {
+ // Given
+ let members = ChannelInfoMockUtils.setupMockMembers(
+ count: 4,
+ currentUserId: chatClient.currentUserId!,
+ onlineUserIndexes: [0] // Only current user is online
+ )
+ let group = ChatChannel.mock(
+ cid: .unique,
+ name: "Test Group",
+ ownCapabilities: [.updateChannelMembers],
+ lastActiveMembers: members,
+ memberCount: members.count
+ )
+ let viewModel = ChatChannelInfoViewModel(channel: group)
+ // Select the second participant (index 1) who is offline
+ viewModel.selectedParticipant = viewModel.displayedParticipants[1]
+
+ // When
+ let view = ChatChannelInfoView(viewModel: viewModel)
+ .applyDefaultSize()
+
+ // Then
+ assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
+ }
}
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_navigationBarAppearance.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_navigationBarAppearance.1.png
index d3eeb6fa4..ee9473b91 100644
Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_navigationBarAppearance.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_navigationBarAppearance.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedBasicActionsSnapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedBasicActionsSnapshot.1.png
new file mode 100644
index 000000000..e33a9c85d
Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedBasicActionsSnapshot.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedOfflineUserSnapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedOfflineUserSnapshot.1.png
new file mode 100644
index 000000000..a026fb013
Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedOfflineUserSnapshot.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedWithMuteActionsSnapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedWithMuteActionsSnapshot.1.png
new file mode 100644
index 000000000..e33a9c85d
Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedWithMuteActionsSnapshot.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedWithRemoveActionSnapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedWithRemoveActionSnapshot.1.png
new file mode 100644
index 000000000..e33a9c85d
Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedWithRemoveActionSnapshot.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift
index 403e2dff7..e775bf807 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelDataSource_Tests.swift
@@ -165,6 +165,66 @@ class ChatChannelDataSource_Tests: StreamChatTestCase {
XCTAssert(noMessagesCall == false)
XCTAssert(messagesCall == true)
}
+
+ // MARK: - firstUnreadMessageId Tests
+
+ func test_channelDataSource_firstUnreadMessageId_whenControllerHasFirstUnreadMessageId() {
+ // Given
+ let firstUnreadMessageId = "first-unread-message-id"
+ let controller = makeChannelController(messages: [message])
+ controller.mockFirstUnreadMessageId = firstUnreadMessageId
+ let channelDataSource = ChatChannelDataSource(controller: controller)
+
+ // When
+ let result = channelDataSource.firstUnreadMessageId
+
+ // Then
+ XCTAssertEqual(result, firstUnreadMessageId)
+ }
+
+ func test_channelDataSource_firstUnreadMessageId_whenNilAndCurrentUserHasRead() {
+ // Given
+ let currentUserId = chatClient.currentUserId!
+ let read = ChatChannelRead.mock(
+ lastReadAt: Date(),
+ lastReadMessageId: nil,
+ unreadMessagesCount: 0,
+ user: .mock(id: currentUserId)
+ )
+ let channel = ChatChannel.mockDMChannel(reads: [read])
+ let controller = makeChannelController(messages: [.mock(), .mock(), message])
+ controller.channel_mock = channel
+ controller.mockFirstUnreadMessageId = nil
+ let channelDataSource = ChatChannelDataSource(controller: controller)
+
+ // When
+ let result = channelDataSource.firstUnreadMessageId
+
+ // Then
+ XCTAssertEqual(result, message.id)
+ }
+
+ func test_channelDataSource_firstUnreadMessageId_whenNilAndCurrentUserHasNotRead() {
+ // Given
+ let otherUserId = UserId.unique
+ let read = ChatChannelRead.mock(
+ lastReadAt: Date(),
+ lastReadMessageId: nil,
+ unreadMessagesCount: 0,
+ user: .mock(id: otherUserId)
+ )
+ let channel = ChatChannel.mockDMChannel(reads: [read])
+ let controller = makeChannelController(messages: [message])
+ controller.channel_mock = channel
+ controller.mockFirstUnreadMessageId = .unique
+ let channelDataSource = ChatChannelDataSource(controller: controller)
+
+ // When
+ let result = channelDataSource.firstUnreadMessageId
+
+ // Then
+ XCTAssertNotEqual(result, message.id)
+ }
// MARK: - private
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift
index 679f69c86..34495e0d3 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift
@@ -584,6 +584,81 @@ class ChatChannelViewModel_Tests: StreamChatTestCase {
XCTAssertEqual(1, channelController.markReadCallCount)
XCTAssertNotNil(viewModel.firstUnreadMessageId)
}
+
+ // MARK: - currentUserMarkedMessageUnread Tests
+
+ func test_chatChannelVM_currentUserMarkedMessageUnread_initialValue() {
+ // Given
+ let channelController = makeChannelController()
+ let viewModel = ChatChannelViewModel(channelController: channelController)
+
+ // Then
+ XCTAssertFalse(viewModel.currentUserMarkedMessageUnread)
+ }
+
+ func test_chatChannelVM_sendReadEventIfNeeded_whenCurrentUserMarkedMessageUnreadIsTrue() {
+ // Given
+ let message = ChatMessage.mock()
+ let channelController = makeChannelController(messages: [message])
+ channelController.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 1, mentions: 0))
+ let viewModel = ChatChannelViewModel(channelController: channelController)
+ viewModel.currentUserMarkedMessageUnread = true
+ viewModel.throttler = Throttler_Mock(interval: 0)
+
+ // When
+ viewModel.handleMessageAppear(index: 0, scrollDirection: .down)
+
+ // Then
+ XCTAssertEqual(0, channelController.markReadCallCount)
+ }
+
+ func test_chatChannelVM_sendReadEventIfNeeded_whenCurrentUserMarkedMessageUnreadIsFalse() {
+ // Given
+ let message = ChatMessage.mock()
+ let channelController = makeChannelController(messages: [message])
+ channelController.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 1, mentions: 0))
+ let viewModel = ChatChannelViewModel(channelController: channelController)
+ viewModel.currentUserMarkedMessageUnread = false
+ viewModel.throttler = Throttler_Mock(interval: 0)
+
+ // When
+ viewModel.handleMessageAppear(index: 0, scrollDirection: .down)
+
+ // Then
+ XCTAssertEqual(1, channelController.markReadCallCount)
+ }
+
+ func test_chatChannelVM_sendReadEventIfNeeded_whenChannelHasNoUnreadMessages() {
+ // Given
+ let message = ChatMessage.mock()
+ let channelController = makeChannelController(messages: [message])
+ channelController.channel_mock = .mock(cid: .unique, unreadCount: ChannelUnreadCount(messages: 0, mentions: 0))
+ let viewModel = ChatChannelViewModel(channelController: channelController)
+ viewModel.currentUserMarkedMessageUnread = false
+ viewModel.throttler = Throttler_Mock(interval: 0)
+
+ // When
+ viewModel.handleMessageAppear(index: 0, scrollDirection: .down)
+
+ // Then
+ XCTAssertEqual(0, channelController.markReadCallCount)
+ }
+
+ func test_chatChannelVM_sendReadEventIfNeeded_whenChannelIsNil() {
+ // Given
+ let message = ChatMessage.mock()
+ let channelController = makeChannelController(messages: [message])
+ channelController.channel_mock = nil
+ let viewModel = ChatChannelViewModel(channelController: channelController)
+ viewModel.currentUserMarkedMessageUnread = false
+ viewModel.throttler = Throttler_Mock(interval: 0)
+
+ // When
+ viewModel.handleMessageAppear(index: 0, scrollDirection: .down)
+
+ // Then
+ XCTAssertEqual(0, channelController.markReadCallCount)
+ }
// MARK: - private
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift
index f693428d1..56e084464 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift
@@ -401,6 +401,79 @@ class MessageActions_Tests: StreamChatTestCase {
XCTAssertTrue(messageActions.contains(where: { $0.title == "Delete Message" }))
}
+ // MARK: - MessageActionsResolver Tests
+
+ func test_messageActionsResolver_markUnreadAction() {
+ // Given
+ let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
+ let channelController = makeChannelController(messages: [message])
+ let viewModel = ChatChannelViewModel(channelController: channelController)
+ let resolver = MessageActionsResolver()
+ let actionInfo = MessageActionInfo(message: message, identifier: MessageActionId.markUnread)
+
+ // When
+ resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)
+
+ // Then
+ XCTAssertEqual(viewModel.firstUnreadMessageId, message.messageId)
+ XCTAssertTrue(viewModel.currentUserMarkedMessageUnread)
+ XCTAssertEqual(viewModel.scrolledId, message.messageId)
+ XCTAssertFalse(viewModel.reactionsShown)
+ }
+
+ func test_messageActionsResolver_inlineReplyAction() {
+ // Given
+ let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
+ let channelController = makeChannelController(messages: [message])
+ let viewModel = ChatChannelViewModel(channelController: channelController)
+ let resolver = MessageActionsResolver()
+ let actionInfo = MessageActionInfo(message: message, identifier: "inlineReply")
+
+ // When
+ resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)
+
+ // Then
+ XCTAssertEqual(viewModel.quotedMessage, message)
+ XCTAssertNil(viewModel.editedMessage)
+ XCTAssertFalse(viewModel.reactionsShown)
+ }
+
+ func test_messageActionsResolver_editAction() {
+ // Given
+ let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
+ let channelController = makeChannelController(messages: [message])
+ let viewModel = ChatChannelViewModel(channelController: channelController)
+ let resolver = MessageActionsResolver()
+ let actionInfo = MessageActionInfo(message: message, identifier: "edit")
+
+ // When
+ resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)
+
+ // Then
+ XCTAssertEqual(viewModel.editedMessage, message)
+ XCTAssertNil(viewModel.quotedMessage)
+ XCTAssertFalse(viewModel.reactionsShown)
+ }
+
+ func test_messageActionsResolver_unknownAction() {
+ // Given
+ let message = ChatMessage.mock(id: "test-message-id", cid: .unique, text: "Test message")
+ let channelController = makeChannelController(messages: [message])
+ let viewModel = ChatChannelViewModel(channelController: channelController)
+ let resolver = MessageActionsResolver()
+ let actionInfo = MessageActionInfo(message: message, identifier: "unknown")
+
+ // When
+ resolver.resolveMessageAction(info: actionInfo, viewModel: viewModel)
+
+ // Then
+ XCTAssertNil(viewModel.quotedMessage)
+ XCTAssertNil(viewModel.editedMessage)
+ XCTAssertNil(viewModel.firstUnreadMessageId)
+ XCTAssertFalse(viewModel.currentUserMarkedMessageUnread)
+ XCTAssertFalse(viewModel.reactionsShown)
+ }
+
// MARK: - Private
private var mockDMChannel: ChatChannel {
@@ -415,4 +488,17 @@ class MessageActions_Tests: StreamChatTestCase {
]
)
}
+
+ private func makeChannelController(messages: [ChatMessage] = []) -> ChatChannelController_Mock {
+ let channelController = ChatChannelTestHelpers.makeChannelController(
+ chatClient: chatClient,
+ messages: messages
+ )
+ channelController.simulateInitial(
+ channel: .mockDMChannel(),
+ messages: messages,
+ state: .initialized
+ )
+ return channelController
+ }
}
diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift
index f08a45d56..555f95bbd 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift
@@ -575,6 +575,38 @@ class MessageView_Tests: StreamChatTestCase {
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
}
+ func test_linkAttachmentView_customColors_snapshot() {
+ // Given
+ var colorPalette = ColorPalette()
+ colorPalette.messageLinkAttachmentAuthorColor = .orange
+ colorPalette.messageLinkAttachmentTitleColor = .blue
+ colorPalette.messageLinkAttachmentTextColor = .red
+ streamChat = StreamChat(
+ chatClient: chatClient,
+ appearance: .init(colors: colorPalette)
+ )
+
+ // When
+ let view = LinkAttachmentView(
+ linkAttachment: .mock(
+ id: .unique,
+ originalURL: URL(string: "https://getstream.io")!,
+ title: "Stream",
+ text: "Some link text description",
+ author: "Nuno Vieira",
+ titleLink: nil,
+ assetURL: nil,
+ previewURL: .localYodaImage
+ ),
+ width: 200,
+ isFirst: true
+ )
+ .frame(width: 200, height: 140)
+
+ // Then
+ assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
+ }
+
func test_linkAttachmentView_shouldNotRenderLinkPreviewWithOtherAttachments() {
// Given
let messageWithLinkAndImages = ChatMessage.mock(
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
new file mode 100644
index 000000000..85badcc77
Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageView_Tests/test_linkAttachmentView_customColors_snapshot.1.png differ
diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift
index 2082d3731..ba358e7f2 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift
@@ -491,6 +491,47 @@ class ChatChannelListViewModel_Tests: StreamChatTestCase {
}
wait(for: [expectation], timeout: 1.0)
}
+
+ func test_openChannel_whenSearching_shouldClearSearchState() {
+ // Given
+ let existingChannel = ChatChannel.mockDMChannel()
+ let channelListController = makeChannelListController(channels: [existingChannel])
+ let viewModel = ChatChannelListViewModel(
+ channelListController: channelListController,
+ selectedChannelId: nil,
+ searchType: .messages
+ )
+ viewModel.searchText = "query"
+ XCTAssertTrue(viewModel.isSearching, "Precondition failed: isSearching should be true before opening a channel")
+
+ // When
+ viewModel.openChannel(with: existingChannel.cid)
+
+ // Then
+ XCTAssertFalse(viewModel.isSearching, "isSearching should be false after opening a channel")
+ XCTAssertEqual(viewModel.searchText, "", "searchText should be cleared after opening a channel")
+ XCTAssertNil(viewModel.messageSearchController, "Message search controller should be cleared when search ends")
+ XCTAssertNil(viewModel.channelListSearchController, "Channel search controller should be cleared when search ends")
+ }
+
+ func test_openChannel_whenSelectedChannelIsSet_shouldClearSelectedChannel() {
+ // Given
+ let existingChannel = ChatChannel.mockDMChannel()
+ let targetChannel = ChatChannel.mockDMChannel() // not in the list
+ let channelListController = makeChannelListController(channels: [existingChannel])
+ let viewModel = ChatChannelListViewModel(
+ channelListController: channelListController,
+ selectedChannelId: nil
+ )
+ viewModel.selectedChannel = existingChannel.channelSelectionInfo
+ XCTAssertNotNil(viewModel.selectedChannel, "Precondition failed: selectedChannel should be set before opening a channel")
+
+ // When
+ viewModel.openChannel(with: targetChannel.cid)
+
+ // Then
+ XCTAssertNil(viewModel.selectedChannel, "selectedChannel should be cleared immediately when opening another channel")
+ }
// MARK: - private
diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsViewModel_Tests.swift
index 9972da5be..b4cb3a665 100644
--- a/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsViewModel_Tests.swift
+++ b/StreamChatSwiftUITests/Tests/ChatChannelList/MoreChannelActionsViewModel_Tests.swift
@@ -16,19 +16,23 @@ class MoreChannelActionsViewModel_Tests: StreamChatTestCase {
streamChat = StreamChat(chatClient: chatClient, utils: utils)
}
- func test_moreActionsVM_membersLoaded() {
+ func test_moreActionsVM_membersLoaded() throws {
// Given
+ let currentUserId = try XCTUnwrap(streamChat?.chatClient.currentUserId)
let memberId: String = .unique
let viewModel = makeMoreActionsViewModel(
- members: [.mock(id: memberId, isOnline: true)]
+ members: [
+ .mock(id: memberId, isOnline: true),
+ .mock(id: currentUserId, isOnline: true)
+ ]
)
// When
let members = viewModel.members
// Then
- XCTAssert(members.count == 1)
- XCTAssert(members[0].id == memberId)
+ XCTAssert(members.count == 2)
+ XCTAssert(members.map(\.id) == [memberId, currentUserId])
}
func test_moreActionsVM_chatHeaderInfo() {
diff --git a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift
index 22897c1d8..d807c9860 100644
--- a/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift
+++ b/StreamChatSwiftUITests/Tests/StreamChatTestCase.swift
@@ -41,6 +41,7 @@ open class StreamChatTestCase: XCTestCase {
appearance.colors.navigationBarTitle = .blue
appearance.colors.navigationBarSubtitle = .cyan
appearance.colors.navigationBarBackground = .yellow
+ appearance.colors.navigationBarGlyph = .green
}
}
}
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index a413ff5e8..da6f0de56 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -491,9 +491,25 @@ lane :lint_pr do
danger(dangerfile: 'Dangerfile') if is_ci
end
+def check_foundation_import
+ files_missing_imports = []
+ Dir.glob("Sources/**/*.swift").each do |file|
+ next if file =~ /(StreamNuke|StreamSwiftyGif)/
+
+ files_missing_imports << file unless File.read(file).match?(/import (Foundation|UIKit|SwiftUI|Combine|StreamChat)/)
+ end
+
+ unless files_missing_imports.empty?
+ UI.error("Files missing 'Foundation' import:")
+ files_missing_imports.each { |file| UI.error("- #{file}") }
+ UI.user_error!("Lint check failed.")
+ end
+end
+
desc 'Run source code formatting/linting'
lane :run_swift_format do |options|
Dir.chdir('..') do
+ check_foundation_import
strict = options[:strict] ? '--lint' : nil
sources_matrix[:swiftformat].each do |path|
sh("swiftformat #{strict} --config .swiftformat #{path}")
@@ -547,12 +563,15 @@ end
lane :validate_public_interface do
next unless is_check_required(sources: sources_matrix[:public_interface], force_check: @force_check)
- # Run the analysis on the current branch
+ # Get branch names
original_branch = current_branch
+ target_branch = ENV['GITHUB_BASE_REF'] || (original_branch.include?('release/') ? 'main' : 'develop')
+ UI.important("Target branch: #{target_branch} 🕊️")
+
+ # Run the analysis on the current branch
sh('interface-analyser analysis ../Sources/ public_interface_current.json')
# Checkout the target branch
- target_branch = original_branch.include?('release/') ? 'main' : 'develop'
sh("git fetch origin #{target_branch}")
sh("git checkout #{target_branch}")
diff --git a/fastlane/Scanfile b/fastlane/Scanfile
index cd2beff1f..adc7b4d14 100644
--- a/fastlane/Scanfile
+++ b/fastlane/Scanfile
@@ -1,19 +1,2 @@
-# For more information about this configuration visit
-# https://docs.fastlane.tools/actions/scan/#scanfile
-
-# In general, you can use the options available
-# fastlane scan --help
-
-devices(["iPhone 12"])
-
-# Needed for Sonar
-code_coverage(true)
-
-# Our integration tests need to run in parallel
-disable_concurrent_testing(true)
-
-configuration("Debug")
-
+configuration('Debug')
result_bundle(true)
-
-skip_slack(true)