diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index fba733984a..21e0be3ad0 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -50,6 +50,7 @@ import com.wire.kalium.logic.feature.message.SendKnockUseCase import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendMultipartMessageUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase +import com.wire.kalium.logic.feature.message.SearchMessagesGloballyUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.message.composite.SendButtonActionMessageUseCase import com.wire.kalium.logic.feature.message.draft.GetMessageDraftUseCase @@ -180,6 +181,10 @@ class MessageModule { ): GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase = messageScope.getPaginatedFlowOfMessagesBySearchQueryAndConversation + @Provides + fun provideSearchMessagesGloballyUseCase(messageScope: MessageScope): SearchMessagesGloballyUseCase = + messageScope.searchMessagesGlobally + @Provides fun provideGetSearchedConversationMessagePositionUseCase(messageScope: MessageScope): GetSearchedConversationMessagePositionUseCase = messageScope.getSearchedConversationMessagePosition diff --git a/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt b/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt index 9caf91f352..96682fa54b 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt @@ -112,6 +112,7 @@ import com.wire.android.ui.home.drawer.HomeDrawerViewModel import com.wire.android.ui.home.gallery.MediaGalleryViewModel import com.wire.android.ui.home.messagecomposer.location.LocationPickerViewModel import com.wire.android.ui.home.newconversation.NewConversationViewModel +import com.wire.android.ui.home.search.GlobalSearchViewModel import com.wire.android.ui.home.settings.SettingsViewModel import com.wire.android.ui.home.settings.SettingsViewModelFactory import com.wire.android.ui.home.settings.account.MyAccountViewModel @@ -214,6 +215,12 @@ object WireMetroViewModelBindings { fun newConversationViewModel(factory: HomeViewModelFactory): ViewModel = factory.newConversationViewModel() + @Provides + @IntoMap + @ViewModelKey(GlobalSearchViewModel::class) + fun globalSearchViewModel(factory: HomeViewModelFactory): ViewModel = + factory.globalSearchViewModel() + @Provides @IntoMap @ViewModelKey(CallFeedbackViewModel::class) diff --git a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt index 5ccf20a12d..e7c12230a6 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt @@ -25,6 +25,7 @@ import com.wire.android.R import com.ramcosta.composedestinations.generated.app.destinations.AllConversationsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.ArchiveScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.GlobalCellsScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.GlobalSearchScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.MeetingsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.SettingsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.VaultScreenDestination @@ -50,6 +51,13 @@ sealed class HomeDestination( direction = AllConversationsScreenDestination ) + data object GlobalSearch : HomeDestination( + title = UIText.StringResource(R.string.global_search_screen_title), + icon = R.drawable.ic_conversation, + withUserAvatar = false, + direction = GlobalSearchScreenDestination + ) + data object Settings : HomeDestination( title = UIText.StringResource(R.string.settings_screen_title), icon = R.drawable.ic_settings, @@ -142,6 +150,6 @@ sealed class HomeDestination( values().find { it.direction.baseRoute == fullRoute.getBaseRoute() } fun values(): Array = - arrayOf(Conversations, Settings, Vault, Archive, Support, TeamManagement, WhatsNew, Cells, Meetings) + arrayOf(Conversations, GlobalSearch, Settings, Vault, Archive, Support, TeamManagement, WhatsNew, Cells, Meetings) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index e0fc1c693c..79e4d909ab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -39,6 +39,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.ramcosta.composedestinations.generated.app.destinations.ConversationFoldersScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.GlobalSearchScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.NewConversationSearchPeopleScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination @@ -261,6 +262,10 @@ fun HomeContent( } fun openWireHomeDestination(item: HomeDestination) { + if (item == HomeDestination.GlobalSearch) { + navigator.navigate(NavigationCommand(GlobalSearchScreenDestination)) + return + } item.direction.handleNavigation( context = context, handleOtherDirection = { direction -> diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelFactory.kt index e08e8f1fc7..dcd7ed08bf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelFactory.kt @@ -31,6 +31,7 @@ import com.wire.android.ui.home.conversationslist.ConversationListViewModelImpl import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.ui.home.drawer.HomeDrawerViewModel import com.wire.android.ui.home.newconversation.NewConversationViewModel +import com.wire.android.ui.home.search.GlobalSearchViewModel import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.UiTextResolver @@ -42,6 +43,7 @@ import com.wire.kalium.logic.feature.client.IsWireCellsEnabledUseCase import com.wire.kalium.logic.feature.client.NeedsToRegisterClientUseCase import com.wire.kalium.logic.feature.client.ObserveIsWireCellsEnabledUseCase import com.wire.kalium.logic.feature.conversation.ObserveArchivedUnreadConversationsCountUseCase +import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetailsWithEventsUseCase import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMetadataUseCase import com.wire.kalium.logic.feature.conversation.createconversation.CreateChannelUseCase @@ -49,6 +51,7 @@ import com.wire.kalium.logic.feature.conversation.createconversation.CreateRegul import com.wire.kalium.logic.feature.debug.ObserveDebugCRLExpirationAfterOneMinuteUseCase import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase +import com.wire.kalium.logic.feature.message.SearchMessagesGloballyUseCase import com.wire.kalium.logic.feature.personaltoteamaccount.CanMigrateFromPersonalToTeamUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import com.wire.kalium.logic.feature.server.GetTeamUrlUseCase @@ -95,6 +98,8 @@ class HomeViewModelFactory @Inject constructor( private val getSelfUser: GetSelfUserUseCase, private val getDefaultProtocol: GetDefaultProtocolUseCase, private val observeIsAppsAllowedForUsage: ObserveIsAppsAllowedForUsageUseCase, + private val searchMessagesGlobally: SearchMessagesGloballyUseCase, + private val observeConversationDetails: ObserveConversationDetailsUseCase, ) { fun homeViewModel(savedStateHandle: SavedStateHandle) = HomeViewModel( savedStateHandle = savedStateHandle, @@ -156,4 +161,11 @@ class HomeViewModelFactory @Inject constructor( isWireCellsFeatureEnabled = isWireCellsEnabled, observeIsAppsAllowedForUsage = observeIsAppsAllowedForUsage, ) + + fun globalSearchViewModel() = GlobalSearchViewModel( + searchMessagesGlobally = searchMessagesGlobally, + observeConversationDetails = observeConversationDetails, + dispatchers = dispatcher, + uiTextResolver = uiTextResolver, + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelGraph.kt index fc058d13cb..ef830caa9b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelGraph.kt @@ -32,6 +32,7 @@ import com.wire.android.ui.home.conversationslist.ConversationListViewModelPrevi import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.ui.home.drawer.HomeDrawerViewModel import com.wire.android.ui.home.newconversation.NewConversationViewModel +import com.wire.android.ui.home.search.GlobalSearchViewModel import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory @@ -78,3 +79,7 @@ fun newConversationViewModel( @Composable fun featureFlagNotificationViewModel(): FeatureFlagNotificationViewModel = sessionKeyedMetroViewModel() + +@Composable +fun globalSearchViewModel(): GlobalSearchViewModel = + sessionKeyedMetroViewModel() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt index 768404d89c..5aabfa74e9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt @@ -79,6 +79,9 @@ class HomeDrawerViewModel( buildList { add(DrawerUiItem.RegularItem(destination = HomeDestination.Conversations)) + if (BuildConfig.FEATURE_GLOBAL_SEARCH) { + add(DrawerUiItem.RegularItem(destination = HomeDestination.GlobalSearch)) + } if (shouldShowCells) { add(DrawerUiItem.RegularItem(destination = HomeDestination.Cells)) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/search/GlobalSearchScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/search/GlobalSearchScreen.kt new file mode 100644 index 0000000000..093ef5f6c7 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/search/GlobalSearchScreen.kt @@ -0,0 +1,363 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.search + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination +import com.wire.android.R +import com.wire.android.navigation.BackStackMode +import com.wire.android.navigation.NavigationCommand +import com.wire.android.navigation.Navigator +import com.wire.android.navigation.annotation.app.WireRootDestination +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topBarElevation +import com.wire.android.ui.common.topappbar.search.SearchTopBar +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.globalSearchViewModel +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.QueryMatchExtractor +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.data.id.ConversationId + +@WireRootDestination(style = PopUpNavigationAnimation::class) +@Composable +fun GlobalSearchScreen( + navigator: Navigator, + viewModel: GlobalSearchViewModel = globalSearchViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + GlobalSearchContent( + searchQueryTextState = viewModel.searchQueryTextState, + state = state, + onFilterSelected = viewModel::onFilterSelected, + onResultClick = { result -> + navigator.navigate( + NavigationCommand( + ConversationScreenDestination( + navArgs = ConversationNavArgs( + conversationId = result.conversationId, + searchedMessageId = result.messageId, + ) + ), + BackStackMode.UPDATE_EXISTED + ) + ) + }, + onCloseSearchClicked = navigator::navigateBack, + ) +} + +@Composable +fun GlobalSearchContent( + searchQueryTextState: TextFieldState, + state: GlobalSearchState, + onFilterSelected: (GlobalSearchFilter) -> Unit, + onResultClick: (GlobalSearchResultItem) -> Unit, + onCloseSearchClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + val lazyListState = rememberLazyListState() + val maxAppBarElevation = MaterialTheme.wireDimensions.topBarShadowElevation + + WireScaffold( + modifier = modifier, + topBar = { + Column { + Surface( + shadowElevation = lazyListState.topBarElevation(maxAppBarElevation), + color = MaterialTheme.wireColorScheme.background + ) { + SearchTopBar( + isSearchActive = true, + searchBarHint = stringResource(R.string.global_search_hint), + searchQueryTextState = searchQueryTextState, + onCloseSearchClicked = onCloseSearchClicked, + isLoading = state.isLoading, + ) + } + GlobalSearchFilters( + selectedFilter = state.selectedFilter, + onFilterSelected = onFilterSelected, + ) + } + }, + content = { internalPadding -> + Box( + modifier = Modifier + .padding(internalPadding) + .fillMaxSize() + ) { + when { + state.searchQuery.isBlank() -> GlobalSearchEmptyContent() + state.hasError -> GlobalSearchMessage(text = stringResource(R.string.global_search_error)) + !state.isLoading && state.visibleResults.isEmpty() -> + GlobalSearchMessage(text = stringResource(R.string.label_search_messages_no_results)) + + else -> GlobalSearchResults( + results = state.visibleResults, + searchQuery = state.searchQuery, + onResultClick = onResultClick, + lazyListState = lazyListState, + ) + } + + if (state.isLoading && state.visibleResults.isEmpty() && state.searchQuery.isNotBlank()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } + ) +} + +@Composable +private fun GlobalSearchFilters( + selectedFilter: GlobalSearchFilter, + onFilterSelected: (GlobalSearchFilter) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding( + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing8x, + ), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x), + ) { + GlobalSearchFilter.entries.forEach { filter -> + FilterChip( + selected = selectedFilter == filter, + enabled = filter.isEnabled, + onClick = { onFilterSelected(filter) }, + label = { Text(text = filter.label()) }, + ) + } + } +} + +@Composable +private fun GlobalSearchFilter.label(): String = when (this) { + GlobalSearchFilter.All -> stringResource(R.string.global_search_filter_all) + GlobalSearchFilter.Messages -> stringResource(R.string.global_search_filter_messages) + GlobalSearchFilter.People -> stringResource(R.string.global_search_filter_people) + GlobalSearchFilter.Files -> stringResource(R.string.global_search_filter_files) + GlobalSearchFilter.Links -> stringResource(R.string.global_search_filter_links) + GlobalSearchFilter.Media -> stringResource(R.string.global_search_filter_media) +} + +@Composable +private fun GlobalSearchResults( + results: List, + searchQuery: String, + onResultClick: (GlobalSearchResultItem) -> Unit, + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState(), +) { + LazyColumn( + state = lazyListState, + modifier = modifier.fillMaxSize() + ) { + items( + items = results, + key = { "${it.conversationId}:${it.messageId}" } + ) { item -> + GlobalSearchResultRow( + item = item, + searchQuery = searchQuery, + onClick = { onResultClick(item) }, + ) + HorizontalDivider(color = MaterialTheme.wireColorScheme.outline) + } + } +} + +@Composable +private fun GlobalSearchResultRow( + item: GlobalSearchResultItem, + searchQuery: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable( + onClickLabel = stringResource(R.string.content_description_open_label), + onClick = onClick, + ) + .padding( + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing12x, + ), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing12x), + verticalAlignment = Alignment.Top, + ) { + Icon( + painter = painterResource(R.drawable.ic_conversation), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.secondaryText, + modifier = Modifier + .padding(top = dimensions().spacing4x) + .size(dimensions().spacing16x) + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(dimensions().spacing4x), + ) { + Text( + text = highlightedSnippet(item.preview, searchQuery), + style = MaterialTheme.wireTypography.body01, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${item.senderName} - ${item.conversationName}", + style = MaterialTheme.wireTypography.body02.copy(color = MaterialTheme.wireColorScheme.secondaryText), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun highlightedSnippet(text: String, searchQuery: String) = buildAnnotatedString { + append(text) + QueryMatchExtractor.extractQueryMatchIndexes( + matchText = searchQuery, + text = text, + ).forEach { match -> + addStyle( + style = SpanStyle( + background = MaterialTheme.wireColorScheme.highlight, + color = MaterialTheme.wireColorScheme.onHighlight, + ), + start = match.startIndex, + end = match.endIndex, + ) + } +} + +@Composable +private fun GlobalSearchEmptyContent( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(dimensions().spacing24x), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.wireColorScheme.secondaryText), + modifier = Modifier.size(dimensions().spacing32x) + ) + Text( + text = stringResource(R.string.global_search_empty_title), + style = MaterialTheme.wireTypography.body01.copy(color = MaterialTheme.wireColorScheme.secondaryText), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = dimensions().spacing16x) + ) + } +} + +@Composable +private fun GlobalSearchMessage( + text: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(dimensions().spacing24x), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.wireTypography.body01.copy(color = MaterialTheme.wireColorScheme.secondaryText), + textAlign = TextAlign.Center, + ) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewGlobalSearchContent() = WireTheme { + GlobalSearchContent( + searchQueryTextState = TextFieldState(initialText = "release"), + state = GlobalSearchState( + searchQuery = "release", + results = listOf( + GlobalSearchResultItem( + messageId = "messageId", + conversationId = ConversationId("conversationId", "domain"), + senderName = "Alice", + conversationName = "Product Planning", + preview = "We agreed to move the release to Friday." + ) + ) + ), + onFilterSelected = {}, + onResultClick = {}, + onCloseSearchClicked = {}, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/search/GlobalSearchState.kt b/app/src/main/kotlin/com/wire/android/ui/home/search/GlobalSearchState.kt new file mode 100644 index 0000000000..7497a02e0b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/search/GlobalSearchState.kt @@ -0,0 +1,59 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.search + +import com.wire.android.util.EMPTY +import com.wire.kalium.logic.data.id.ConversationId + +data class GlobalSearchState( + val searchQuery: String = String.EMPTY, + val selectedFilter: GlobalSearchFilter = GlobalSearchFilter.All, + val results: List = emptyList(), + val isLoading: Boolean = false, + val hasError: Boolean = false, +) { + val visibleResults: List + get() = when (selectedFilter) { + GlobalSearchFilter.All, + GlobalSearchFilter.Messages -> results + + GlobalSearchFilter.People, + GlobalSearchFilter.Files, + GlobalSearchFilter.Links, + GlobalSearchFilter.Media -> emptyList() + } +} + +enum class GlobalSearchFilter( + val isEnabled: Boolean +) { + All(isEnabled = true), + Messages(isEnabled = true), + People(isEnabled = false), + Files(isEnabled = false), + Links(isEnabled = false), + Media(isEnabled = false), +} + +data class GlobalSearchResultItem( + val messageId: String, + val conversationId: ConversationId, + val senderName: String, + val conversationName: String, + val preview: String, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/search/GlobalSearchViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/search/GlobalSearchViewModel.kt new file mode 100644 index 0000000000..679abaeaa2 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/search/GlobalSearchViewModel.kt @@ -0,0 +1,164 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.search + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.R +import com.wire.android.ui.common.DEFAULT_SEARCH_QUERY_DEBOUNCE +import com.wire.android.ui.common.textfield.textAsFlow +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.UiTextResolver +import com.wire.kalium.logic.data.conversation.ConversationDetails +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.Message +import com.wire.kalium.logic.data.message.MessageContent +import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.message.SearchMessagesGloballyUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class GlobalSearchViewModel( + private val searchMessagesGlobally: SearchMessagesGloballyUseCase, + private val observeConversationDetails: ObserveConversationDetailsUseCase, + private val dispatchers: DispatcherProvider, + private val uiTextResolver: UiTextResolver, +) : ViewModel() { + + val searchQueryTextState: TextFieldState = TextFieldState() + + private val conversationNameCache = mutableMapOf() + private val mutableState = MutableStateFlow(GlobalSearchState()) + val state: StateFlow = mutableState.asStateFlow() + + init { + viewModelScope.launch { + searchQueryTextState.textAsFlow() + .map { it.toString() } + .distinctUntilChanged() + .debounce(DEFAULT_SEARCH_QUERY_DEBOUNCE) + .collect { searchQuery -> + search(searchQuery) + } + } + } + + fun onFilterSelected(filter: GlobalSearchFilter) { + if (filter.isEnabled) { + mutableState.value = mutableState.value.copy(selectedFilter = filter) + } + } + + private suspend fun search(searchQuery: String) { + val normalizedQuery = searchQuery.trim() + if (normalizedQuery.isBlank()) { + mutableState.value = GlobalSearchState(searchQuery = searchQuery) + return + } + + mutableState.value = mutableState.value.copy( + searchQuery = normalizedQuery, + isLoading = true, + hasError = false, + ) + + when (val result = searchMessagesGlobally(normalizedQuery, GLOBAL_SEARCH_LIMIT)) { + is SearchMessagesGloballyUseCase.Result.Success -> { + val items = withContext(dispatchers.io()) { + result.messages.mapNotNull { message -> + message.toGlobalSearchResultItem() + } + } + mutableState.value = mutableState.value.copy( + results = items, + isLoading = false, + hasError = false, + ) + } + + is SearchMessagesGloballyUseCase.Result.Failure -> { + mutableState.value = mutableState.value.copy( + results = emptyList(), + isLoading = false, + hasError = true, + ) + } + } + } + + private suspend fun Message.Standalone.toGlobalSearchResultItem(): GlobalSearchResultItem? { + val preview = content.previewText() ?: return null + return GlobalSearchResultItem( + messageId = id, + conversationId = conversationId, + senderName = senderName(), + conversationName = conversationName(), + preview = preview, + ) + } + + private fun MessageContent.previewText(): String? = when (this) { + is MessageContent.Text -> value + is MessageContent.Multipart -> value + is MessageContent.Composite -> textContent?.value + else -> null + }?.takeIf { it.isNotBlank() } + + private fun Message.Standalone.senderName(): String = + (this as? Message.Sendable)?.senderUserName + ?: sender?.name + ?: deletedAccountLabel() + + private suspend fun Message.Standalone.conversationName(): String = + conversationNameCache[conversationId] ?: runCatching { + observeConversationDetails(conversationId).firstOrNull() + } + .getOrNull() + ?.let { result -> + when (result) { + is ObserveConversationDetailsUseCase.Result.Success -> + result.conversationDetails.displayName() + + is ObserveConversationDetailsUseCase.Result.Failure -> null + } + } + .orEmpty() + .ifBlank { deletedAccountLabel() } + .also { conversationNameCache[conversationId] = it } + + private fun ConversationDetails.displayName(): String = when (this) { + is ConversationDetails.OneOne -> otherUser.name.orEmpty() + else -> conversation.name.orEmpty() + } + + private fun deletedAccountLabel(): String = + uiTextResolver.resolve(UIText.StringResource(R.string.member_name_deleted_label)) + + private companion object { + const val GLOBAL_SEARCH_LIMIT = 100 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99b9479c8d..4be6df39c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -208,6 +208,7 @@ open device details Close connection request view Search text messages + Search Wire Open overview of pictures and files Close add participants view Your profile @@ -992,6 +993,16 @@ Search all messages in this conversation. No results could be found.\nPlease refine your search and try again. Search + Search Wire + Search Wire + Search messages across Wire. + Search is unavailable right now.\nPlease try again. + All + Messages + People + Files + Links + Media Media Pictures diff --git a/app/src/test/kotlin/com/wire/android/ui/home/search/GlobalSearchViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/search/GlobalSearchViewModelTest.kt new file mode 100644 index 0000000000..3dc86038dd --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/search/GlobalSearchViewModelTest.kt @@ -0,0 +1,183 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.search + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.SnapshotExtension +import com.wire.android.config.TestDispatcherProvider +import com.wire.android.framework.TestConversationDetails +import com.wire.android.framework.TestMessage +import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.UiTextResolver +import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.logic.data.message.MessageContent +import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.message.SearchMessagesGloballyUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) +class GlobalSearchViewModelTest { + + @Test + fun givenBlankSearchQuery_whenQueryChanges_thenSearchIsNotTriggered() = runTest { + val (arrangement, viewModel) = Arrangement().arrange() + + viewModel.searchQueryTextState.setTextAndPlaceCursorAtEnd(" ") + advanceUntilIdle() + + assertTrue(viewModel.state.value.results.isEmpty()) + coVerify(exactly = 0) { + arrangement.searchMessagesGlobally(any(), any(), any()) + } + } + + @Test + fun givenSearchResults_whenQueryChanges_thenMessagesAreMappedToUiState() = runTest { + val message = TestMessage.TEXT_MESSAGE.copy( + id = "message-id", + content = MessageContent.Text("The release moves to Friday"), + senderUserName = "Alice" + ) + val (arrangement, viewModel) = Arrangement() + .withGlobalSearchResult(SearchMessagesGloballyUseCase.Result.Success(listOf(message))) + .arrange() + + viewModel.searchQueryTextState.setTextAndPlaceCursorAtEnd("release") + advanceUntilIdle() + + val state = viewModel.state.value + assertFalse(state.isLoading) + assertFalse(state.hasError) + assertEquals("release", state.searchQuery) + assertEquals(1, state.results.size) + assertEquals("message-id", state.results.first().messageId) + assertEquals("Alice", state.results.first().senderName) + assertEquals("GROUP Name", state.results.first().conversationName) + assertEquals("The release moves to Friday", state.results.first().preview) + coVerify { + arrangement.searchMessagesGlobally("release", 100, 0) + } + } + + @Test + fun givenSearchFailure_whenQueryChanges_thenErrorStateIsShown() = runTest { + val (_, viewModel) = Arrangement() + .withGlobalSearchResult(SearchMessagesGloballyUseCase.Result.Failure(StorageFailure.DataNotFound)) + .arrange() + + viewModel.searchQueryTextState.setTextAndPlaceCursorAtEnd("release") + advanceUntilIdle() + + assertTrue(viewModel.state.value.hasError) + assertFalse(viewModel.state.value.isLoading) + assertTrue(viewModel.state.value.results.isEmpty()) + } + + @Test + fun givenDisabledFilter_whenFilterSelected_thenSelectedFilterDoesNotChange() = runTest { + val (_, viewModel) = Arrangement().arrange() + + viewModel.onFilterSelected(GlobalSearchFilter.Files) + + assertEquals(GlobalSearchFilter.All, viewModel.state.value.selectedFilter) + } + + @Test + fun givenConversationDetailsThrows_whenQueryChanges_thenDeletedAccountLabelIsUsed() = runTest { + val message = TestMessage.TEXT_MESSAGE.copy( + id = "message-id", + content = MessageContent.Text("The release moves to Friday"), + senderUserName = null, + sender = null, + ) + val (_, viewModel) = Arrangement() + .withGlobalSearchResult(SearchMessagesGloballyUseCase.Result.Success(listOf(message))) + .withConversationDetailsThrowing() + .arrange() + + viewModel.searchQueryTextState.setTextAndPlaceCursorAtEnd("release") + advanceUntilIdle() + + val result = viewModel.state.value.results.first() + assertEquals(DELETED_ACCOUNT_LABEL, result.senderName) + assertEquals(DELETED_ACCOUNT_LABEL, result.conversationName) + } + + private class Arrangement { + @MockK + lateinit var searchMessagesGlobally: SearchMessagesGloballyUseCase + + @MockK + lateinit var observeConversationDetails: ObserveConversationDetailsUseCase + + private val viewModel: GlobalSearchViewModel by lazy { + GlobalSearchViewModel( + searchMessagesGlobally = searchMessagesGlobally, + observeConversationDetails = observeConversationDetails, + dispatchers = TestDispatcherProvider(), + uiTextResolver = object : UiTextResolver { + override fun resolve(text: UIText): String = DELETED_ACCOUNT_LABEL + override fun localeTag(): String = "en" + } + ) + } + + init { + MockKAnnotations.init(this) + coEvery { + searchMessagesGlobally(any(), any(), any()) + } returns SearchMessagesGloballyUseCase.Result.Success(emptyList()) + coEvery { + observeConversationDetails(any()) + } returns flowOf(ObserveConversationDetailsUseCase.Result.Success(TestConversationDetails.GROUP)) + } + + fun withGlobalSearchResult(result: SearchMessagesGloballyUseCase.Result) = apply { + coEvery { + searchMessagesGlobally(any(), any(), any()) + } returns result + } + + fun withConversationDetailsThrowing() = apply { + coEvery { + observeConversationDetails(any()) + } returns flow { throw IllegalArgumentException("Field otherUserID in OneOnOne null when unpacking db content") } + } + + fun arrange() = this to viewModel + } + + private companion object { + const val DELETED_ACCOUNT_LABEL = "Deleted account" + } +} diff --git a/buildSrc/src/main/kotlin/customization/FeatureFlags.kt b/buildSrc/src/main/kotlin/customization/FeatureFlags.kt index dc9044ae8e..44be1a0386 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureFlags.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureFlags.kt @@ -25,7 +25,8 @@ import flavor.ProductFlavors */ enum class Features { FEATURE_SEARCH, - FEATURE_CONVERSATIONS + FEATURE_CONVERSATIONS, + FEATURE_GLOBAL_SEARCH } /** @@ -42,7 +43,8 @@ object FeatureFlags { //Enabled Features for INTERNAL Product Flavor ProductFlavors.Alpha to setOf( - Features.FEATURE_CONVERSATIONS + Features.FEATURE_CONVERSATIONS, + Features.FEATURE_GLOBAL_SEARCH ) ) } diff --git a/kalium b/kalium index 7fed478f72..38b717027a 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 7fed478f72d9f885211a0f088ab40c96cc5670ec +Subproject commit 38b717027a20ee643a9df0cab5ad03ebc604c9a5