diff --git a/CHANGELOG.md b/CHANGELOG.md index 0764ed887..939aefda5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ 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) ### 🐞 Fixed - Fix openChannel not working when searching or another chat shown [#975](https://github.com/GetStream/stream-chat-swiftui/pull/975) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoView.swift index 1b1ec098c..7c0d9abc2 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,6 +129,17 @@ public struct ChatChannelInfoView: View, KeyboardReadable ) } } + + if let selectedParticipant = viewModel.selectedParticipant { + ParticipantInfoView( + participant: selectedParticipant, + actions: viewModel.participantActions(for: selectedParticipant) + ) { + withAnimation { + viewModel.selectedParticipant = nil + } + } + } } .toolbarThemed { ToolbarItem(placement: .principal) { @@ -170,4 +179,8 @@ public struct ChatChannelInfoView: View, KeyboardReadable .dismissKeyboardOnTap(enabled: viewModel.keyboardShown) .background(Color(colors.background).edgesIgnoringSafeArea(.bottom)) } + + private var popupShown: Bool { + viewModel.addUsersShown || viewModel.selectedParticipant != nil + } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/ChatChannelInfoViewModel.swift index ab17499df..9c1c474c7 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,12 +34,13 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe @Published public var channelId = UUID().uuidString @Published public var keyboardShown = false @Published public var addUsersShown = false - + @Published public var selectedParticipant: ParticipantInfo? + public 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) } } @@ -49,13 +50,15 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe public 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/Generated/L10n.swift b/Sources/StreamChatSwiftUI/Generated/L10n.swift index a9d6cf0bd..42c784636 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/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/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index f84a29315..27ab148a7 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 */, 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_participantSelectedBasicActionsSnapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedBasicActionsSnapshot.1.png new file mode 100644 index 000000000..54d3e8832 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..8660fb9c8 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..54d3e8832 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..54d3e8832 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/__Snapshots__/ChatChannelInfoView_Tests/test_chatChannelInfoView_participantSelectedWithRemoveActionSnapshot.1.png differ