diff --git a/CHANGELOG.md b/CHANGELOG.md index e363a2b6f..f5f00b728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +### ✅ Added +- Add support for Channel Search in the Channel List [#628](https://github.com/GetStream/stream-chat-swiftui/pull/628) ### 🐞 Fixed - Fix crash when opening message overlay in iPad with a TabBar [#627](https://github.com/GetStream/stream-chat-swiftui/pull/627) diff --git a/DemoAppSwiftUI/DemoAppSwiftUIApp.swift b/DemoAppSwiftUI/DemoAppSwiftUIApp.swift index 87c6ea6ac..4f3927c72 100644 --- a/DemoAppSwiftUI/DemoAppSwiftUIApp.swift +++ b/DemoAppSwiftUI/DemoAppSwiftUIApp.swift @@ -18,7 +18,11 @@ struct DemoAppSwiftUIApp: App { var channelListController: ChatChannelListController? { appState.channelListController } - + + var channelListSearchType: ChannelListSearchType { + .messages + } + var body: some Scene { WindowGroup { switch appState.userState { @@ -64,12 +68,14 @@ struct DemoAppSwiftUIApp: App { ChatChannelListView( viewFactory: DemoAppFactory.shared, channelListController: channelListController, - selectedChannelId: notificationsHandler.notificationChannelId + selectedChannelId: notificationsHandler.notificationChannelId, + searchType: channelListSearchType ) } else { ChatChannelListView( viewFactory: DemoAppFactory.shared, - channelListController: channelListController + channelListController: channelListController, + searchType: channelListSearchType ) } } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift index 497cce770..d340e49c6 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift @@ -19,7 +19,7 @@ public struct ChatChannelListView: View { private let customOnItemTap: ((ChatChannel) -> Void)? private var embedInNavigationView: Bool private var handleTabBarVisibility: Bool - + /// Creates a channel list view. /// /// - Parameters: @@ -31,6 +31,7 @@ public struct ChatChannelListView: View { /// - selectedChannelId: The id of a channel to be opened after the initial channel list load. /// - handleTabBarVisibility: True, if TabBar visibility should be automatically updated. /// - embedInNavigationView: True, if the channel list view should be embedded in a navigation stack. + /// - searchType: The type of data the channel list should perform a search. By default it searches messages. /// /// Changing the instance of the passed in `viewModel` or `channelListController` does not have an effect without reloading the channel list view by assigning a custom identity. The custom identity should be refreshed when either of the passed in instances have been recreated. /// ```swift @@ -47,12 +48,14 @@ public struct ChatChannelListView: View { onItemTap: ((ChatChannel) -> Void)? = nil, selectedChannelId: String? = nil, handleTabBarVisibility: Bool = true, - embedInNavigationView: Bool = true + embedInNavigationView: Bool = true, + searchType: ChannelListSearchType = .messages ) { _viewModel = StateObject( wrappedValue: viewModel ?? ViewModelsFactory.makeChannelListViewModel( channelListController: channelListController, - selectedChannelId: selectedChannelId + selectedChannelId: selectedChannelId, + searchType: searchType ) ) self.viewFactory = viewFactory diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift index 1fe060e30..fd3f580f8 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift @@ -30,8 +30,6 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController /// Temporarly holding changes while message list is shown. private var queuedChannelsChanges = LazyCachedMapCollection() - private var messageSearchController: ChatMessageSearchController? - private var timer: Timer? /// Controls loading the channels. @@ -103,6 +101,13 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController } } + private let searchType: ChannelListSearchType + internal var channelListSearchController: ChatChannelListController? + internal var messageSearchController: ChatMessageSearchController? + + @Published public var loadingSearchResults = false + @Published public var searchResults = [ChannelSelectionInfo]() + @Published var hideTabBar = false @Published public var searchText = "" { didSet { if searchText != oldValue { @@ -111,10 +116,6 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController } } - @Published public var loadingSearchResults = false - @Published public var searchResults = [ChannelSelectionInfo]() - @Published var hideTabBar = false - public var isSearching: Bool { !searchText.isEmpty } @@ -125,10 +126,13 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController /// - channelListController: A controller providing the list of channels. If nil, a controller with default `ChannelListQuery` is created. /// - selectedChannelId: The id of a channel to select. If the channel is not part of the channel list query, no channel is selected. /// Consider using ``ChatChannelScreen`` for presenting channels what might not be part of the initial page of channels. + /// - searchType: The type of data the channel list should perform a search. public init( channelListController: ChatChannelListController? = nil, - selectedChannelId: String? = nil + selectedChannelId: String? = nil, + searchType: ChannelListSearchType = .channels ) { + self.searchType = searchType self.selectedChannelId = selectedChannelId if let channelListController = channelListController { controller = channelListController @@ -168,21 +172,13 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController } public func loadAdditionalSearchResults(index: Int) { - guard let messageSearchController = messageSearchController else { - return - } - - if index < messageSearchController.messages.count - 10 { - return - } - - if !loadingNextChannels { - loadingNextChannels = true - messageSearchController.loadNextMessages { [weak self] _ in - guard let self = self else { return } - self.loadingNextChannels = false - self.updateSearchResults() - } + switch searchType { + case .channels: + loadAdditionalChannelSearchResults(index: index) + case .messages: + loadAdditionalMessageSearchResults(index: index) + default: + break } } @@ -258,7 +254,7 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController // MARK: - ChatMessageSearchControllerDelegate public func controller(_ controller: ChatMessageSearchController, didChangeMessages changes: [ListChange]) { - updateSearchResults() + updateMessageSearchResults() } // MARK: - private @@ -340,7 +336,93 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController .filter { $0.id != chatClient.currentUserId } } - private func updateSearchResults() { + private func handleSearchTextChange() { + if searchText.isEmpty { + clearSearchResults() + return + } + + switch searchType { + case .messages: + performMessageSearch() + case .channels: + performChannelSearch() + default: + break + } + } + + private func loadAdditionalMessageSearchResults(index: Int) { + guard let messageSearchController = messageSearchController else { + return + } + + if index < messageSearchController.messages.count - 10 { + return + } + + if !loadingNextChannels { + loadingNextChannels = true + messageSearchController.loadNextMessages { [weak self] _ in + guard let self = self else { return } + self.loadingNextChannels = false + self.updateMessageSearchResults() + } + } + } + + private func loadAdditionalChannelSearchResults(index: Int) { + guard let channelListSearchController = self.channelListSearchController else { + return + } + + if index < channelListSearchController.channels.count - 10 { + return + } + + if !loadingNextChannels { + loadingNextChannels = true + channelListSearchController.loadNextChannels { [weak self] _ in + guard let self = self else { return } + self.loadingNextChannels = false + self.updateChannelSearchResults() + } + } + } + + private func performMessageSearch() { + guard let userId = chatClient.currentUserId else { return } + messageSearchController = chatClient.messageSearchController() + messageSearchController?.delegate = self + let query = MessageSearchQuery( + channelFilter: .containMembers(userIds: [userId]), + messageFilter: .autocomplete(.text, text: searchText) + ) + loadingSearchResults = true + messageSearchController?.search(query: query, completion: { [weak self] _ in + self?.loadingSearchResults = false + self?.updateMessageSearchResults() + }) + } + + private func performChannelSearch() { + guard let userId = chatClient.currentUserId else { return } + var query = ChannelListQuery( + filter: .and([ + .autocomplete(.name, text: searchText), + .containMembers(userIds: [userId]) + ]) + ) + query.options = [] + channelListSearchController = chatClient.channelListController(query: query) + loadingSearchResults = true + channelListSearchController?.synchronize { [weak self] _ in + self?.loadingSearchResults = false + self?.updateChannelSearchResults() + } + } + + private func updateMessageSearchResults() { guard let messageSearchController = messageSearchController else { return } @@ -351,26 +433,28 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController } } - private func handleSearchTextChange() { - if !searchText.isEmpty { - guard let userId = chatClient.currentUserId else { return } - messageSearchController = chatClient.messageSearchController() - messageSearchController?.delegate = self - let query = MessageSearchQuery( - channelFilter: .containMembers(userIds: [userId]), - messageFilter: .autocomplete(.text, text: searchText) - ) - loadingSearchResults = true - messageSearchController?.search(query: query, completion: { [weak self] _ in - self?.loadingSearchResults = false - self?.updateSearchResults() - }) - } else { - messageSearchController?.delegate = nil - messageSearchController = nil - searchResults = [] - updateChannels() + private func updateChannelSearchResults() { + guard let channelListSearchController = self.channelListSearchController else { + return } + + searchResults = channelListSearchController.channels + .compactMap { channel in + ChannelSelectionInfo( + channel: channel, + message: channel.previewMessage, + searchType: .channels + ) + } + } + + private func clearSearchResults() { + messageSearchController?.delegate = nil + messageSearchController = nil + channelListSearchController?.delegate = nil + channelListSearchController = nil + searchResults = [] + updateChannels() } private func observeClientIdChange() { @@ -491,3 +575,15 @@ public enum ChannelPopupType { /// Shows the 'more actions' popup. case moreActions(ChatChannel) } + +/// The type of data the channel list should perform a search. +public struct ChannelListSearchType: Equatable { + let type: String + + private init(type: String) { + self.type = type + } + + public static var channels = Self(type: "channels") + public static var messages = Self(type: "messages") +} diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift index f6e1ef97f..439d6e354 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift @@ -73,10 +73,16 @@ public struct ChannelSelectionInfo: Identifiable { public let channel: ChatChannel public let message: ChatMessage? public var injectedChannelInfo: InjectedChannelInfo? + public var searchType: ChannelListSearchType - public init(channel: ChatChannel, message: ChatMessage?) { + public init( + channel: ChatChannel, + message: ChatMessage?, + searchType: ChannelListSearchType = .messages + ) { self.channel = channel self.message = message + self.searchType = searchType if let message = message { id = "\(channel.cid.id)-\(message.id)" } else { diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift b/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift index a5099cfb3..5af48672d 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift @@ -143,7 +143,7 @@ struct SearchResultItem: View { ChatTitleView(name: channelName) HStack { - SubtitleText(text: searchResult.message?.text ?? "") + SubtitleText(text: messageText) Spacer() SubtitleText(text: timestampText) } @@ -161,4 +161,18 @@ struct SearchResultItem: View { return "" } } + + private var messageText: String { + switch searchResult.searchType { + case .channels: + guard let previewMessage = searchResult.message else { + return L10n.Channel.Item.emptyMessages + } + return utils.messagePreviewFormatter.format(previewMessage) + case .messages: + return searchResult.message?.text ?? "" + default: + return "" + } + } } diff --git a/Sources/StreamChatSwiftUI/ViewModelsFactory.swift b/Sources/StreamChatSwiftUI/ViewModelsFactory.swift index 37280fb6b..9b1ce724e 100644 --- a/Sources/StreamChatSwiftUI/ViewModelsFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewModelsFactory.swift @@ -14,14 +14,17 @@ public class ViewModelsFactory { /// - Parameters: /// - channelListController: possibility to inject custom channel list controller. /// - selectedChannelId: pre-selected channel id (used for deeplinking). + /// - searchType: The type of data the channel list should perform a search. By default it searches messages. /// - Returns: `ChatChannelListViewModel`. public static func makeChannelListViewModel( channelListController: ChatChannelListController? = nil, - selectedChannelId: String? = nil + selectedChannelId: String? = nil, + searchType: ChannelListSearchType = .messages ) -> ChatChannelListViewModel { ChatChannelListViewModel( channelListController: channelListController, - selectedChannelId: selectedChannelId + selectedChannelId: selectedChannelId, + searchType: searchType ) } diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift index 194d4fe89..b01623dcc 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift @@ -325,6 +325,42 @@ class ChatChannelListViewModel_Tests: StreamChatTestCase { cancellable.cancel() } + // MARK: - Search + + func test_loadAdditionalSearchResults_whenSearchTypeIsChannels_shouldLoadNextChannels() { + let searchChannelListController = makeChannelListController() + let viewModel = makeDefaultChannelListVM(searchType: .channels) + viewModel.channelListSearchController = searchChannelListController + + viewModel.loadAdditionalSearchResults(index: 1) + + XCTAssertEqual(searchChannelListController.loadNextChannelsCallCount, 1) + } + + func test_loadAdditionalSearchResults_whenSearchTypeIsMessages_shouldLoadNextMessages() { + let messageSearchController = ChatMessageSearchController_Mock.mock() + let viewModel = makeDefaultChannelListVM(searchType: .messages) + viewModel.messageSearchController = messageSearchController + + viewModel.loadAdditionalSearchResults(index: 1) + + XCTAssertEqual(messageSearchController.loadNextMessagesCallCount, 1) + } + + func test_searchText_whenChanged_whenSearchTypeIsChannels_shouldPerformChannelSearch() { + let viewModel = makeDefaultChannelListVM(searchType: .channels) + viewModel.searchText = "Hey" + XCTAssertNotNil(viewModel.channelListSearchController) + XCTAssertNil(viewModel.messageSearchController) + } + + func test_searchText_whenChanged_whenSearchTypeIsMessages_shouldPerformMessageSearch() { + let viewModel = makeDefaultChannelListVM(searchType: .messages) + viewModel.searchText = "Hey" + XCTAssertNil(viewModel.channelListSearchController) + XCTAssertNotNil(viewModel.messageSearchController) + } + // MARK: - private private func makeChannelListController( @@ -343,14 +379,15 @@ class ChatChannelListViewModel_Tests: StreamChatTestCase { } private func makeDefaultChannelListVM( - channels: [ChatChannel] = [] + channels: [ChatChannel] = [], + searchType: ChannelListSearchType = .messages ) -> ChatChannelListViewModel { let channelListController = makeChannelListController(channels: channels) let viewModel = ChatChannelListViewModel( channelListController: channelListController, - selectedChannelId: nil + selectedChannelId: nil, + searchType: searchType ) - return viewModel } } diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift index cb5d0dff4..e379b2700 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift @@ -53,6 +53,52 @@ class SearchResultsView_Tests: StreamChatTestCase { assertSnapshot(matching: view, as: .image) } + func test_searchResultsView_snapshotResults_whenChannelSearch() { + // Given + let channel1 = ChatChannel.mock(cid: .unique, name: "Test 1") + let message1 = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Test 1", + author: .mock(id: .unique, name: "Luke") + ) + let result1 = ChannelSelectionInfo( + channel: channel1, + message: message1, + searchType: .channels + ) + let channel2 = ChatChannel.mock(cid: .unique, name: "Test 2") + let message2 = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Test 2", + author: .mock(id: .unique, name: "Han Solo") + ) + let result2 = ChannelSelectionInfo( + channel: channel2, + message: message2, + searchType: .channels + ) + let searchResults = [result1, result2] + + // When + let view = SearchResultsView( + factory: DefaultViewFactory.shared, + selectedChannel: .constant(nil), + searchResults: searchResults, + loadingSearchResults: false, + onlineIndicatorShown: { _ in false }, + channelNaming: { $0.name ?? "" }, + imageLoader: { _ in UIImage(systemName: "person.circle")! }, + onSearchResultTap: { _ in }, + onItemAppear: { _ in } + ) + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image) + } + func test_searchResultsView_snapshotNoResults() { // Given let searchResults = [ChannelSelectionInfo]() diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/SearchResultsView_Tests/test_searchResultsView_snapshotResults_whenChannelSearch.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/SearchResultsView_Tests/test_searchResultsView_snapshotResults_whenChannelSearch.1.png new file mode 100644 index 000000000..9287d48c4 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/SearchResultsView_Tests/test_searchResultsView_snapshotResults_whenChannelSearch.1.png differ