diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt index 58690696b0f..bf6419fe4c7 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt @@ -36,6 +36,7 @@ import com.wire.kalium.logic.feature.conversation.IsOneToOneConversationCreatedU import com.wire.kalium.logic.feature.conversation.JoinConversationViaCodeUseCase import com.wire.kalium.logic.feature.conversation.CheckConversationLeaveConditionsUseCase import com.wire.kalium.logic.feature.conversation.LeaveConversationUseCase +import com.wire.kalium.logic.feature.conversation.ObserveEligibleMembersForConversationAdminRoleUseCase import com.wire.kalium.logic.feature.conversation.NotifyConversationIsOpenUseCase import com.wire.kalium.logic.feature.conversation.ObserveArchivedUnreadConversationsCountUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase @@ -209,6 +210,13 @@ class ConversationModule { fun provideCheckConversationLeaveConditionsUseCase(conversationScope: ConversationScope): CheckConversationLeaveConditionsUseCase = conversationScope.checkConversationLeaveConditions + @ViewModelScoped + @Provides + fun provideObserveEligibleMembersForConversationAdminRoleUseCase( + conversationScope: ConversationScope + ): ObserveEligibleMembersForConversationAdminRoleUseCase = + conversationScope.observeEligibleMembersForConversationAdminRole + @ViewModelScoped @Provides fun provideUpdateConversationMutedStatusUseCase(conversationScope: ConversationScope): UpdateConversationMutedStatusUseCase = diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt index 04fdb3811b4..74ea32b1794 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt @@ -35,6 +35,7 @@ import com.wire.android.ui.home.HomeSnackBarMessage import com.wire.android.ui.home.conversationslist.model.DeleteGroupDialogState import com.wire.android.ui.home.conversationslist.model.DialogState import com.wire.android.ui.home.conversationslist.model.LeaveGroupDialogState +import com.wire.android.ui.home.conversationslist.model.LeaveGroupOptionsDialogState import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.workmanager.worker.enqueueConversationDeletionLocally import com.wire.kalium.logic.data.conversation.ConversationFolder @@ -46,9 +47,9 @@ import com.wire.kalium.logic.feature.connection.BlockUserUseCase import com.wire.kalium.logic.feature.connection.UnblockUserResult import com.wire.kalium.logic.feature.connection.UnblockUserUseCase import com.wire.kalium.logic.feature.conversation.ArchiveStatusUpdateResult +import com.wire.kalium.logic.feature.conversation.CheckConversationLeaveConditionsUseCase import com.wire.kalium.logic.feature.conversation.ClearConversationContentUseCase import com.wire.kalium.logic.feature.conversation.ConversationUpdateStatusResult -import com.wire.kalium.logic.feature.conversation.CheckConversationLeaveConditionsUseCase import com.wire.kalium.logic.feature.conversation.LeaveConversationUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase @@ -70,6 +71,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onCompletion @@ -82,6 +85,7 @@ import javax.inject.Inject @ViewModelScopedPreview interface ConversationOptionsMenuViewModel : ActionsManager { val leaveGroupDialogState: VisibilityState get() = VisibilityState() + val leaveGroupOptionsDialogState: VisibilityState get() = VisibilityState() val deleteGroupDialogState: VisibilityState get() = VisibilityState() val deleteGroupLocallyDialogState: VisibilityState get() = VisibilityState() val blockUserDialogState: VisibilityState get() = VisibilityState() @@ -129,6 +133,7 @@ class ConversationOptionsMenuViewModelImpl @Inject constructor( private val nonCancellableIOContext = NonCancellable + dispatchers.io() private val conversationStateFlow: ConcurrentHashMap> = ConcurrentHashMap() override val leaveGroupDialogState: VisibilityState by mutableStateOf(VisibilityState()) + override val leaveGroupOptionsDialogState: VisibilityState by mutableStateOf(VisibilityState()) override val deleteGroupDialogState: VisibilityState by mutableStateOf(VisibilityState()) override val deleteGroupLocallyDialogState: VisibilityState by mutableStateOf(VisibilityState()) override val blockUserDialogState: VisibilityState by mutableStateOf(VisibilityState()) @@ -253,7 +258,14 @@ class ConversationOptionsMenuViewModelImpl @Inject constructor( when (val result = checkConversationLeaveConditions(leaveGroupState.conversationId)) { CheckConversationLeaveConditionsUseCase.Result.Allow -> leaveGroupDialogState.show(leaveGroupState) is CheckConversationLeaveConditionsUseCase.Result.DoNotAllow -> { - appLogger.i("TODO: Show new leave options dialog: $result") + leaveGroupOptionsDialogState.show( + LeaveGroupOptionsDialogState( + conversationId = leaveGroupState.conversationId, + conversationName = leaveGroupState.conversationName, + showPromoteOption = result.eligibleUsersAvailable, + canDeleteGroup = canDeleteGroup(leaveGroupState.conversationId), + ) + ) } is CheckConversationLeaveConditionsUseCase.Result.Error -> { onMessage(HomeSnackBarMessage.LeaveConversationError) @@ -265,6 +277,12 @@ class ConversationOptionsMenuViewModelImpl @Inject constructor( } } + private suspend fun canDeleteGroup(conversationId: ConversationId) = observeConversationStateFlow(conversationId) + .filterIsInstance() + .firstOrNull() + ?.conversation + ?.canDeleteGroup() ?: false + override fun leaveGroup(conversationId: ConversationId, conversationName: String, shouldDelete: Boolean) { viewModelScope.launch { leaveGroupDialogState.update { it.copy(loading = true) } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsModalSheetLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsModalSheetLayout.kt index 40fe2f2ea4e..8435f34b163 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsModalSheetLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsModalSheetLayout.kt @@ -39,8 +39,10 @@ import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.home.conversations.details.dialog.ClearConversationContentDialog import com.wire.android.ui.home.conversations.details.menu.DeleteConversationGroupDialog import com.wire.android.ui.home.conversations.details.menu.DeleteConversationGroupLocallyDialog +import com.wire.android.ui.home.conversations.details.menu.LeaveConversationAdminOptionsDialog import com.wire.android.ui.home.conversations.details.menu.LeaveConversationGroupDialog import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs +import com.wire.android.ui.home.conversationslist.model.DeleteGroupDialogState import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.id.ConversationId @@ -53,6 +55,7 @@ fun ConversationOptionsModalSheetLayout( onLeftConversation: () -> Unit = {}, onDeletedConversation: () -> Unit = {}, onDeletedConversationLocally: () -> Unit = {}, + onPromoteAdmin: (ConversationId) -> Unit = {}, openConversationDebugMenu: (ConversationId) -> Unit = {}, viewModel: ConversationOptionsMenuViewModel = hiltViewModelScoped() @@ -63,6 +66,17 @@ fun ConversationOptionsModalSheetLayout( dialogState = viewModel.leaveGroupDialogState, onLeaveGroup = { viewModel.leaveGroup(it.conversationId, it.conversationName, it.shouldDelete) } ) + LeaveConversationAdminOptionsDialog( + dialogState = viewModel.leaveGroupOptionsDialogState, + onPromoteAdmin = { state -> + viewModel.leaveGroupOptionsDialogState.dismiss() + onPromoteAdmin(state.conversationId) + }, + onDeleteGroup = { state -> + viewModel.leaveGroupOptionsDialogState.dismiss() + viewModel.deleteGroupDialogState.show(DeleteGroupDialogState(state.conversationId, state.conversationName)) + }, + ) DeleteConversationGroupDialog( dialogState = viewModel.deleteGroupDialogState, onDeleteGroup = { viewModel.deleteGroup(it.conversationId, it.conversationName) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt index 31502f9f631..53ec0b6b3b3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt @@ -104,7 +104,9 @@ import com.ramcosta.composedestinations.generated.app.destinations.SearchConvers import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.UpdateAppsAccessScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.PromoteAdminScreenDestination import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessParams +import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs import com.wire.android.ui.home.conversations.details.options.GroupConversationOptions import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsState import com.wire.android.ui.home.conversations.details.options.LoadingGroupConversation @@ -299,6 +301,9 @@ fun GroupConversationDetailsScreen( ) ) }, + onPromoteAdmin = { conversationId -> + navigator.navigate(NavigationCommand(PromoteAdminScreenDestination(PromoteAdminNavArgs(conversationId)))) + }, openConversationDebugMenu = { navigator.navigate( NavigationCommand( @@ -374,6 +379,7 @@ private fun GroupConversationDetailsContent( onMoveToFolder: (ConversationFoldersNavArgs) -> Unit = {}, onLeftConversation: () -> Unit = {}, onDeletedConversation: () -> Unit = {}, + onPromoteAdmin: (ConversationId) -> Unit = {}, openConversationDebugMenu: (ConversationId) -> Unit = {}, initialPageIndex: GroupConversationDetailsTabItem = GroupConversationDetailsTabItem.OPTIONS, isScreenLoading: StateFlow = MutableStateFlow(false), @@ -559,6 +565,7 @@ private fun GroupConversationDetailsContent( openConversationFolders = onMoveToFolder, onLeftConversation = onLeftConversation, onDeletedConversation = onDeletedConversation, + onPromoteAdmin = onPromoteAdmin, openConversationDebugMenu = openConversationDebugMenu, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt new file mode 100644 index 00000000000..e6260437d64 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt @@ -0,0 +1,96 @@ +/* + * Wire + * Copyright (C) 2025 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.conversations.details.menu + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.VisibilityState +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.visbility.VisibilityState +import com.wire.android.ui.home.conversationslist.model.LeaveGroupOptionsDialogState + +@Composable +internal fun LeaveConversationAdminOptionsDialog( + dialogState: VisibilityState, + onPromoteAdmin: (LeaveGroupOptionsDialogState) -> Unit, + onDeleteGroup: (LeaveGroupOptionsDialogState) -> Unit, +) { + VisibilityState(dialogState) { state -> + val isInformationalOnly = !state.showPromoteOption && !state.canDeleteGroup + WireDialog( + title = stringResource(id = titleRes(state), state.conversationName), + text = stringResource(id = descriptionRes(state)), + buttonsHorizontalAlignment = false, + onDismiss = dialogState::dismiss, + optionButton1Properties = when { + isInformationalOnly -> WireDialogButtonProperties( + onClick = dialogState::dismiss, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary, + ) + state.showPromoteOption -> WireDialogButtonProperties( + onClick = { onPromoteAdmin(state) }, + text = stringResource(id = R.string.leave_conversation_admin_options_dialog_promote_button), + type = WireDialogButtonType.Primary, + ) + else -> null + }, + optionButton2Properties = when { + !isInformationalOnly && state.canDeleteGroup -> WireDialogButtonProperties( + onClick = { onDeleteGroup(state) }, + text = stringResource(id = R.string.leave_conversation_admin_options_dialog_delete_button), + type = WireDialogButtonType.Primary, + ) + else -> null + }, + dismissButtonProperties = if (isInformationalOnly) { + null + } else { + WireDialogButtonProperties( + onClick = dialogState::dismiss, + text = stringResource(id = R.string.label_cancel), + ) + }, + ) + } +} + +@Composable +private fun descriptionRes(state: LeaveGroupOptionsDialogState): Int = when { + state.showPromoteOption && state.canDeleteGroup -> + R.string.leave_conversation_admin_options_dialog_description_with_promote + + state.showPromoteOption && !state.canDeleteGroup -> + R.string.leave_conversation_admin_options_dialog_description_with_promote_no_delete + + !state.showPromoteOption && state.canDeleteGroup -> + R.string.leave_conversation_admin_options_dialog_description_no_promote + + else -> + R.string.leave_conversation_admin_options_dialog_description_no_promote_no_delete +} + +@Composable +private fun titleRes(state: LeaveGroupOptionsDialogState): Int = when { + state.canDeleteGroup || state.showPromoteOption -> R.string.leave_conversation_dialog_title + else -> R.string.cannot_leave_conversation_dialog_title +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminNavArgs.kt new file mode 100644 index 00000000000..b6248a22053 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminNavArgs.kt @@ -0,0 +1,28 @@ +/* + * Wire + * Copyright (C) 2025 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.conversations.promoteadmin + +import android.os.Parcelable +import com.wire.android.ui.home.conversations.QualifiedIdParceler +import com.wire.kalium.logic.data.id.ConversationId +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler + +@Parcelize +@TypeParceler() +data class PromoteAdminNavArgs(val conversationId: ConversationId) : Parcelable diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt new file mode 100644 index 00000000000..6086c9d21e4 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt @@ -0,0 +1,200 @@ +/* + * Wire + * Copyright (C) 2025 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.conversations.promoteadmin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.wire.android.R +import com.wire.android.model.Clickable +import com.wire.android.model.ItemActionType +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.SearchBarInput +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.home.conversations.search.InternalContactSearchResultItem +import com.wire.android.ui.home.conversationslist.model.Membership +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.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.data.user.ConnectionState +import com.wire.kalium.logic.data.user.UserId +import com.wire.android.ui.common.R as commonR + +@WireRootDestination( + navArgs = PromoteAdminNavArgs::class, + style = PopUpNavigationAnimation::class, +) +@Composable +fun PromoteAdminScreen( + navigator: Navigator, + viewModel: PromoteAdminViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + PromoteAdminContent( + state = state, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onUserSelected = viewModel::onUserSelected, + onPromoteAdminAndLeave = viewModel::onPromoteAdminAndLeave, + onClose = navigator::navigateBack, + ) +} + +@Composable +private fun PromoteAdminContent( + state: PromoteAdminState, + onSearchQueryChanged: (String) -> Unit, + onUserSelected: (UserId) -> Unit, + onPromoteAdminAndLeave: () -> Unit, + onClose: () -> Unit, +) { + val searchTextState = rememberTextFieldState() + + LaunchedEffect(Unit) { + snapshotFlow { searchTextState.text.toString() } + .collect { onSearchQueryChanged(it) } + } + + WireScaffold( + topBar = { + WireCenterAlignedTopAppBar( + title = stringResource(R.string.promote_admin_screen_title), + navigationIconType = NavigationIconType.Close(R.string.content_description_close_button), + elevation = dimensions().spacing0x, + onNavigationPressed = onClose, + ) + }, + bottomBar = { + Surface( + color = MaterialTheme.wireColorScheme.background, + shadowElevation = MaterialTheme.wireDimensions.bottomNavigationShadowElevation, + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing16x) + .height(dimensions().groupButtonHeight), + ) { + WirePrimaryButton( + text = stringResource(R.string.promote_admin_button), + onClick = onPromoteAdminAndLeave, + state = if (state.isButtonEnabled) WireButtonState.Default else WireButtonState.Disabled, + ) + } + } + }, + ) { internalPadding -> + LazyColumn(modifier = Modifier.padding(internalPadding)) { + item { + SearchBarInput( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing8x, + ), + placeholderText = stringResource(R.string.promote_admin_search_placeholder), + leadingIcon = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(start = dimensions().spacing4x) + .size(dimensions().buttonCircleMinSize) + ) { + Icon( + painter = painterResource(id = commonR.drawable.ic_search), + contentDescription = stringResource(commonR.string.content_description_conversation_search_icon), + tint = MaterialTheme.wireColorScheme.onBackground + ) + } + }, + textState = searchTextState, + ) + } + items(state.filteredMembers, key = { it.userId.toString() }) { member -> + InternalContactSearchResultItem( + avatarData = member.avatarData, + name = member.name, + label = member.handle, + membership = Membership.Standard, + searchQuery = state.searchQuery, + connectionState = ConnectionState.ACCEPTED, + isSelected = state.selectedUserId == member.userId, + actionType = ItemActionType.CHECK, + onCheckClickable = Clickable { onUserSelected(member.userId) }, + clickable = Clickable { onUserSelected(member.userId) }, + ) + } + } + } +} + +@PreviewMultipleThemes +@Composable +private fun PreviewPromoteAdminScreen() = WireTheme { + PromoteAdminContent( + state = PromoteAdminState( + filteredMembers = listOf( + PromoteAdminMemberItem( + userId = UserId("user1", "wire.com"), + name = "Alice", + handle = "@alice", + ), + PromoteAdminMemberItem( + userId = UserId("user2", "wire.com"), + name = "Bob", + handle = "@bob", + ), + ), + selectedUserId = UserId("user1", "wire.com"), + ), + onSearchQueryChanged = {}, + onUserSelected = {}, + onPromoteAdminAndLeave = {}, + onClose = {}, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt new file mode 100644 index 00000000000..de5cac8bd0b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt @@ -0,0 +1,117 @@ +/* + * Wire + * Copyright (C) 2025 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.conversations.promoteadmin + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.app.navArgs +import com.wire.android.model.UserAvatarData +import com.wire.android.ui.home.conversations.avatar +import com.wire.kalium.logic.data.conversation.MemberDetails +import com.wire.kalium.logic.data.user.OtherUser +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.conversation.ObserveEligibleMembersForConversationAdminRoleUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PromoteAdminViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val observeEligibleMembers: ObserveEligibleMembersForConversationAdminRoleUseCase, +) : ViewModel() { + + private val navArgs: PromoteAdminNavArgs = savedStateHandle.navArgs() + + private val allMembers = MutableStateFlow>(emptyList()) + private val searchQuery = MutableStateFlow("") + private val selectedUserId = MutableStateFlow(null) + + private val _state: MutableStateFlow = MutableStateFlow(PromoteAdminState()) + val state = _state.asStateFlow() + + init { + viewModelScope.launch { + combine(allMembers, searchQuery, selectedUserId) { members, query, selected -> + PromoteAdminState( + searchQuery = query, + filteredMembers = filter(members, query), + selectedUserId = selected, + isButtonEnabled = selected != null, + ) + }.collect { newState -> + _state.update { newState } + } + } + + viewModelScope.launch { + observeEligibleMembers(navArgs.conversationId).collect { members -> + allMembers.value = members.map { it.toMemberItem() } + } + } + } + + fun onSearchQueryChanged(query: String) { + searchQuery.value = query + } + + fun onUserSelected(userId: UserId) { + selectedUserId.value = if (selectedUserId.value == userId) null else userId + } + + fun onPromoteAdminAndLeave() { + TODO("implement with use cases") + } + + private fun filter(members: List, query: String): List = + if (query.isBlank()) { + members + } else { + val normalized = query.removePrefix("@") + members.filter { + it.name.contains(normalized, ignoreCase = true) || + it.handle.contains(normalized, ignoreCase = true) + } + } + + private fun MemberDetails.toMemberItem() = PromoteAdminMemberItem( + userId = user.id, + name = user.name.orEmpty(), + handle = user.handle.orEmpty(), + avatarData = user.avatar(connectionState = (user as? OtherUser)?.connectionStatus), + ) +} + +data class PromoteAdminState( + val searchQuery: String = "", + val filteredMembers: List = emptyList(), + val selectedUserId: UserId? = null, + val isButtonEnabled: Boolean = false, +) + +data class PromoteAdminMemberItem( + val userId: UserId, + val name: String, + val handle: String, + val avatarData: UserAvatarData = UserAvatarData(), +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 1cabbc64db2..1f75bca5bf1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -37,6 +37,7 @@ import com.ramcosta.composedestinations.generated.app.destinations.ConversationS import com.ramcosta.composedestinations.generated.app.destinations.DebugConversationScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.NewConversationSearchPeopleScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.PromoteAdminScreenDestination import com.wire.android.R import com.wire.android.appLogger import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl @@ -58,6 +59,7 @@ import com.wire.android.ui.common.search.rememberSearchbarState import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.debug.conversation.DebugConversationScreenNavArgs import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState +import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs import com.wire.android.ui.home.conversationslist.common.ConversationList import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationItemType @@ -245,6 +247,7 @@ fun ConversationsScreenContent( ConversationOptionsModalSheetLayout( sheetState = sheetState, openConversationFolders = { navigator.navigate(NavigationCommand(ConversationFoldersScreenDestination(it))) }, + onPromoteAdmin = { navigator.navigate(NavigationCommand(PromoteAdminScreenDestination(PromoteAdminNavArgs(it)))) }, openConversationDebugMenu = { conversationId -> navigator.navigate( NavigationCommand( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt index cfb80a2a7e3..86e8b529980 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt @@ -42,6 +42,13 @@ data class LeaveGroupDialogState( val loading: Boolean = false, ) : GroupDialogState(conversationId, conversationName) +data class LeaveGroupOptionsDialogState( + override val conversationId: ConversationId, + override val conversationName: String, + val showPromoteOption: Boolean, + val canDeleteGroup: Boolean, +) : GroupDialogState(conversationId, conversationName) + data class DeleteGroupDialogState( override val conversationId: ConversationId, override val conversationName: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4796c023380..d0455909e13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -663,8 +663,18 @@ Current apps will be removed from the conversation. New apps will not be allowed. Leave Conversation… Leave “%s”? + Cannot leave “%s”. You will then no longer be able to send or read messages in this conversation on any device. Delete the conversation and its content for me on all my devices + You\'re the only admin.\nPromote another participant before leaving, or delete the group if it is no longer needed. + You\'re the only admin.\nPromote another participant before leaving. + You\'re the only admin. The other participants can\'t be admins.\nAdd at least one team member and promote them as an admin before you leave. Alternatively, delete the group if it is no longer needed. + There are no other eligible admins in the group and as a personal user you cannot delete the group. However, add another team member and make that user admin to delete the group. + Promote new admin + Delete group + Promote new admin + Enter a name or email + Promote as admin and leave group Delete Conversation… Remove “%s”? The conversation will be removed from your conversations list on all devices. You will no longer be able to access the conversation and its content. diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt new file mode 100644 index 00000000000..ed9f2921698 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt @@ -0,0 +1,191 @@ +/* + * Wire + * Copyright (C) 2025 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.conversations.promoteadmin + +import androidx.lifecycle.SavedStateHandle +import com.ramcosta.composedestinations.generated.app.navArgs +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.NavigationTestExtension +import com.wire.android.mapper.testOtherUser +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.conversation.MemberDetails +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.conversation.ObserveEligibleMembersForConversationAdminRoleUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.assertNull +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, NavigationTestExtension::class) +class PromoteAdminViewModelTest { + + @Test + fun givenNoUserSelected_whenScreenLoads_thenButtonIsDisabled() = runTest { + val (_, viewModel) = Arrangement().arrange() + + assertFalse(viewModel.state.value.isButtonEnabled) + assertNull(viewModel.state.value.selectedUserId) + } + + @Test + fun givenUserSelected_whenSameUserSelectedAgain_thenSelectionCleared() = runTest { + val userId = UserId("user1", "wire.com") + val (_, viewModel) = Arrangement().arrange() + + viewModel.onUserSelected(userId) + assertTrue(viewModel.state.value.isButtonEnabled) + + viewModel.onUserSelected(userId) + assertFalse(viewModel.state.value.isButtonEnabled) + assertNull(viewModel.state.value.selectedUserId) + } + + @Test + fun givenUserSelected_whenDifferentUserSelected_thenOnlyNewUserSelected() = runTest { + val userId1 = UserId("user1", "wire.com") + val userId2 = UserId("user2", "wire.com") + val (_, viewModel) = Arrangement().arrange() + + viewModel.onUserSelected(userId1) + assertEquals(userId1, viewModel.state.value.selectedUserId) + + viewModel.onUserSelected(userId2) + assertEquals(userId2, viewModel.state.value.selectedUserId) + } + + @Test + fun givenSearchQuery_whenFiltering_thenOnlyMatchingMembersReturned() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alice"), member(1, "Bob", "bob"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("ali") + advanceUntilIdle() + + val filtered = viewModel.state.value.filteredMembers + assertEquals(1, filtered.size) + assertEquals("Alice", filtered.first().name) + } + + @Test + fun givenEmptySearchQuery_whenFiltering_thenAllMembersReturned() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alice"), member(1, "Bob", "bob"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("") + advanceUntilIdle() + + assertEquals(2, viewModel.state.value.filteredMembers.size) + } + + @Test + fun givenSearchQueryMatchesHandle_whenFiltering_thenMemberReturned() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alicewonder"), member(1, "Bob", "bobby"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("wonder") + advanceUntilIdle() + + val filtered = viewModel.state.value.filteredMembers + assertEquals(1, filtered.size) + assertEquals("Alice", filtered.first().name) + } + + @Test + fun givenSearchQueryStartsWithAt_whenFiltering_thenLeadingAtIsStrippedAndHandleMatched() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alicewonder"), member(1, "Bob", "bobby"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("@alice") + advanceUntilIdle() + + val filtered = viewModel.state.value.filteredMembers + assertEquals(1, filtered.size) + assertEquals("Alice", filtered.first().name) + } + + @Test + fun givenSearchQueryMatchesNeitherNameNorHandle_whenFiltering_thenResultIsEmpty() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alicewonder"), member(1, "Bob", "bobby"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("charlie") + advanceUntilIdle() + + assertTrue(viewModel.state.value.filteredMembers.isEmpty()) + } + + private fun member(index: Int, name: String, handle: String): MemberDetails = + MemberDetails( + testOtherUser(index).copy(name = name, handle = handle), + Conversation.Member.Role.Member, + ) + + @Test + fun givenNoEligibleMembers_whenViewModelCreated_thenAllMembersIsEmpty() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(emptyList()) + .arrange() + + advanceUntilIdle() + + assertTrue(viewModel.state.value.filteredMembers.isEmpty()) + } + + private inner class Arrangement { + @MockK + lateinit var savedStateHandle: SavedStateHandle + + @MockK + lateinit var observeEligibleMembers: ObserveEligibleMembersForConversationAdminRoleUseCase + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { savedStateHandle.navArgs() } returns + PromoteAdminNavArgs(ConversationId("conv1", "wire.com")) + coEvery { observeEligibleMembers(any()) } returns flowOf(emptyList()) + } + + fun withEligibleMembers(members: List) = apply { + coEvery { observeEligibleMembers(any()) } returns flowOf(members) + } + + fun arrange() = this to PromoteAdminViewModel(savedStateHandle, observeEligibleMembers) + } +} diff --git a/kalium b/kalium index f69a608d512..ac51876ffcd 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit f69a608d5126385b2a1284845657c0bbf11f9c65 +Subproject commit ac51876ffcddb1e12b10bdb23aec3d38e6dcdfda