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 @@

- StreamChatSwiftUI + StreamChatSwiftUI

## 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)