From 3c8f4291a59a6b49aa52924a4cdc9c5109428537 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 17 Nov 2025 15:42:56 +0800 Subject: [PATCH 01/23] ManageAdminsScreen and viewmodel --- .../settings/ConversationSettingsNavHost.kt | 30 ++ .../settings/ConversationSettingsViewModel.kt | 2 +- .../groups/BaseGroupMembersViewModel.kt | 5 + .../groups/ManageGroupAdminsViewModel.kt | 314 ++++++++++++++++++ .../groups/ManageGroupMembersViewModel.kt | 15 +- .../groups/compose/ManageGroupAdminsScreen.kt | 275 +++++++++++++++ 6 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 3366c335fe..bfe6dbfa6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -31,10 +31,12 @@ import org.thoughtcrime.securesms.conversation.v2.settings.notification.Notifica import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen import org.thoughtcrime.securesms.groups.compose.InviteAccountIdScreen import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen +import org.thoughtcrime.securesms.groups.compose.ManageGroupAdminsScreen import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.media.MediaOverviewScreen @@ -71,6 +73,16 @@ sealed interface ConversationSettingsDestination: Parcelable { val groupAddress: Address.Group get() = Address.Group(AccountId(address)) } + @Serializable + @Parcelize + data class RouteManageAdmins private constructor( + private val address: String + ): ConversationSettingsDestination { + constructor(groupAddress: Address.Group): this(groupAddress.address) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } + @Serializable @Parcelize data class RouteInviteToGroup private constructor( @@ -213,6 +225,24 @@ fun ConversationSettingsNavHost( ) } + // Manage group Admins + horizontalSlideComposable { backStackEntry -> + val data: RouteManageAdmins = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(data.groupAddress, navigator) + } + + ManageGroupAdminsScreen( + viewModel = viewModel, + onBack = dropUnlessResumed { + handleBack() + }, + ) + } + + // Invite Contacts to group horizontalSlideComposable { backStackEntry -> val data: RouteInviteToGroup = backStackEntry.toRoute() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index f71efb86ee..c76def6309 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -289,7 +289,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( qaTag = R.string.qa_conversation_settings_manage_members, onClick = { (address as? Address.Group)?.let { - navigateTo(ConversationSettingsDestination.RouteManageMembers(it)) + navigateTo(ConversationSettingsDestination.RouteManageAdmins(it)) } } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 9aa032af14..213af849ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -99,6 +99,11 @@ abstract class BaseGroupMembersViewModel( .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin } } .stateIn(viewModelScope, SharingStarted.Lazily, false) + // Output: List of only ADMINS + val adminMembers: StateFlow> = members + .map { list -> list.filter { it.showAsAdmin } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + fun onSearchQueryChanged(query: String) { mutableSearchQuery.value = query } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt new file mode 100644 index 0000000000..b3dd160100 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -0,0 +1,314 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.util.AvatarUtils + +/** + * Admin screen: + * - Shows admins + their promotion status + * - Lets you select admins with failed/sent promotions + * - Bottom tray: "Resend promotions" + * + * No removing members, no invites here. + */ +@HiltViewModel(assistedFactory = ManageGroupAdminsViewModel.Factory::class) +class ManageGroupAdminsViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group, + @Assisted private val navigator: UINavigator, + @ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val groupManager: GroupManagerV2, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : BaseGroupMembersViewModel( + groupAddress = groupAddress, + context = context, + storage = storage, + configFactory = configFactory, + avatarUtils = avatarUtils, + recipientRepository = recipientRepository +) { + private val groupId = groupAddress.accountId + + // Current group name (for header / text, if needed) + val groupName: StateFlow = groupInfo + .map { it?.first?.name.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + + private val _mutableSelectedAdmins = MutableStateFlow(emptySet()) + val selectedAdmins: StateFlow> = _mutableSelectedAdmins + + private val footerCollapsed = MutableStateFlow(false) + + /** + * One option for admins for now: "Promote members" + */ + private val optionsList: List by lazy { + listOf( + OptionsItem( + // use plural version of this string resource + name = context.resources.getQuantityString(R.plurals.promoteMember, 2, 2), + icon = R.drawable.ic_add_admin_custom, + onClick = ::navigateToPromoteMembers + ) + ) + } + + private val _uiState = MutableStateFlow(UiState(options = optionsList)) + val uiState: StateFlow = _uiState + + init { + // Build footer from selected admins + collapsed state + viewModelScope.launch { + kotlinx.coroutines.flow.combine( + selectedAdmins, + footerCollapsed, + ::buildFooterState + ).collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + } + + fun onAdminItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedAdmins.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) + } + _mutableSelectedAdmins.value = newSet + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + private fun navigateToPromoteMembers() { + viewModelScope.launch { + navigator.navigate( + ConversationSettingsDestination.RouteManageMembers(groupAddress) + ) + } + } + + /** + * Resend promotions to all selected admins. + */ + fun onResendPromotionsClicked() { + val selected = selectedAdmins.value + if (selected.isEmpty()) return + + performGroupOperation(showLoading = false) { + val accountIds = selected.map { it.accountId } + + removeSearchState(clearSelection = true) + + _uiState.update { + it.copy( + ongoingAction = context.resources.getQuantityString( + R.plurals.resendingPromotion, + accountIds.size, + accountIds.size + ) + ) + } + + groupManager.promoteMember( + groupId, + accountIds, + isRepromote = true + ) + } + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun clearSelection() { + _mutableSelectedAdmins.value = emptySet() + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + fun onDismissError() { + _uiState.update { it.copy(error = null) } + } + + fun onDismissResend() { + _uiState.update { it.copy(ongoingAction = null) } + } + + /** + * Shared helper for group operations (same pattern with ManageGroupMembersViewModel). + */ + private fun performGroupOperation( + showLoading: Boolean = true, + errorMessage: ((Throwable) -> String?)? = null, + operation: suspend () -> Unit + ) { + viewModelScope.launch { + if (showLoading) { + _uiState.update { it.copy(inProgress = true) } + } + + @Suppress("OPT_IN_USAGE") + val task = GlobalScope.async { + operation() + } + + try { + task.await() + } catch (e: Exception) { + _uiState.update { + it.copy( + error = errorMessage?.invoke(e) + ?: context.getString(R.string.errorUnknown) + ) + } + } finally { + if (showLoading) { + _uiState.update { it.copy(inProgress = false) } + } + } + } + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + + val title = + if (count == 0) GetString("") + else { + GetString( + context.resources.getQuantityString( + R.plurals.adminSelected, + count, + count + ) + ) + } + + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.resendPromotion, count, count) + ), + buttonLabel = GetString(context.getString(R.string.resend)), + isDanger = false, + onClick = { onResendPromotionsClicked() } + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title, + footerActionItems = trayItems + ) + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.DismissError -> onDismissError() + is Commands.DismissResend -> onDismissResend() + is Commands.ToggleFooter -> toggleFooter() + is Commands.CloseFooter, + is Commands.ClearSelection -> clearSelection() + is Commands.MemberClick -> onAdminItemClicked(command.member) + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + data class UiState( + val options: List = emptyList(), + + val inProgress: Boolean = false, + val error: String? = null, + val ongoingAction: String? = null, + + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle: GetString = GetString(""), + val footerActionItems: List = emptyList() + ) + + data class OptionsItem( + val name: String, + @DrawableRes val icon: Int, + @StringRes val qaTag: Int? = null, + val onClick: () -> Unit + ) + + sealed interface Commands { + data object DismissError : Commands + data object DismissResend : Commands + + data object ToggleFooter : Commands + data object CloseFooter : Commands + data object ClearSelection : Commands + + data class RemoveSearchState(val clearSelection: Boolean) : Commands + data class SearchQueryChange(val query: String) : Commands + data class SearchFocusChange(val focus: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + navigator: UINavigator + ): ManageGroupAdminsViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt index 006a61dcd7..9c11ef7ae8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -87,7 +87,19 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ) } - private val _uiState = MutableStateFlow(UiState(options = optionsList)) + private val adminOptionsList: List by lazy { + listOf( + OptionsItem( + // use plural version of this string resource + name = context.resources.getQuantityString(R.plurals.promoteMember,2,2), + icon = R.drawable.ic_add_admin_custom, + onClick = ::navigateToInviteContacts + ), + ) + } + + private val _uiState = + MutableStateFlow(UiState(options = optionsList, adminOptions = adminOptionsList)) val uiState: StateFlow = _uiState private val showRemoveMembersDialog = MutableStateFlow(false) @@ -426,6 +438,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( data class UiState( val options : List = emptyList(), + val adminOptions : List = emptyList(), val inProgress: Boolean = false, val error: String? = null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt new file mode 100644 index 0000000000..614a1c7eed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt @@ -0,0 +1,275 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import network.loki.messenger.R +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.* +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.getCellBottomShape +import org.thoughtcrime.securesms.ui.getCellTopShape +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@Composable +fun ManageGroupAdminsScreen( + viewModel: ManageGroupAdminsViewModel, + onBack: () -> Unit, +) { + ManageAdmins( + onBack = onBack, + uiState = viewModel.uiState.collectAsState().value, + admins = viewModel.adminMembers.collectAsState().value, + selectedMembers = viewModel.selectedAdmins.collectAsState().value, + searchQuery = viewModel.searchQuery.collectAsState().value, + sendCommand = viewModel::onCommand, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageAdmins( + onBack: () -> Unit, + uiState: ManageGroupAdminsViewModel.UiState, + searchQuery: String, + admins: List, + selectedMembers: Set = emptySet(), + sendCommand: (command: ManageGroupAdminsViewModel.Commands) -> Unit, +) { + + val searchFocused = uiState.isSearchFocused + val showingError = uiState.error + val showingOngoingAction = uiState.ongoingAction + + val handleBack: () -> Unit = { + when { + searchFocused -> sendCommand(ManageGroupAdminsViewModel.Commands.RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + + Scaffold( + topBar = { + BackAppBar( + title = stringResource(id = R.string.manageAdmins), + onBack = handleBack, + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = uiState.footer.footerActionItems + ), + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } + ) + } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(R.string.adminCannotBeDemoted), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + AnimatedVisibility( + // show only when add-members is enabled AND search is not focused + visible = !searchFocused, + enter = fadeIn(animationSpec = tween(150)) + + expandVertically( + animationSpec = tween(200), + expandFrom = Alignment.Top + ), + exit = fadeOut(animationSpec = tween(150)) + + shrinkVertically( + animationSpec = tween(180), + shrinkTowards = Alignment.Top + ) + ) { + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing), + ) { + Column { + uiState.options.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + uiState.options.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick, + ) + + if (index != uiState.options.lastIndex) Divider() + } + } + } + } + + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = LocalResources.current.getString(R.string.admins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(admins) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { sendCommand(MemberClick(member)) }, + selected = member in selectedMembers + ) + } + + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } + } +} + + +@Preview +@Composable +private fun PreviewManageAdmins( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ManageAdmins( + onBack = {}, + admins = listOf(), + searchQuery = "", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupAdminsViewModel.UiState( + options = emptyList(), + footer = ManageGroupAdminsViewModel.CollapsibleFooterState( + visible = false, + collapsed = true, + footerActionTitle = GetString("2 Admins Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), + ) + } +} From eb69ef53a44f18fae4664440f88e0d33ae6f74a9 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 17 Nov 2025 17:07:24 +0800 Subject: [PATCH 02/23] Self selection toast --- .../groups/BaseGroupMembersViewModel.kt | 6 ++-- .../groups/ManageGroupAdminsViewModel.kt | 9 +++++- .../securesms/groups/compose/Components.kt | 13 ++++---- .../groups/compose/GroupMembersScreen.kt | 3 ++ .../groups/compose/ManageGroupAdminsScreen.kt | 30 +++++++++++++++++-- .../compose/ManageGroupMembersScreen.kt | 17 +++++++---- 6 files changed, 63 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 213af849ad..7b9cb304ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -155,7 +155,8 @@ abstract class BaseGroupMembersViewModel( showProBadge = proStatus.shouldShowProBadge(), avatarUIData = avatarUtils.getUIDataFromAccountId(memberAccountId.hexString), clickable = !isMyself, - statusLabel = getMemberLabel(status, context, amIAdmin) + statusLabel = getMemberLabel(status, context, amIAdmin), + isSelf = isMyself ) } @@ -229,7 +230,8 @@ data class GroupMemberState( val canRemove: Boolean, val canPromote: Boolean, val clickable: Boolean, - val statusLabel: String + val statusLabel: String, + val isSelf: Boolean ) { val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index b3dd160100..ea129be22c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -253,6 +253,11 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( is Commands.ToggleFooter -> toggleFooter() is Commands.CloseFooter, is Commands.ClearSelection -> clearSelection() + is Commands.SelfClick -> { + _uiState.update { + it.copy(error = context.getString(R.string.adminStatusYou)) + } + } is Commands.MemberClick -> onAdminItemClicked(command.member) is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) @@ -297,7 +302,9 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( data object CloseFooter : Commands data object ClearSelection : Commands - data class RemoveSearchState(val clearSelection: Boolean) : Commands + data object SelfClick : Commands + + class RemoveSearchState(val clearSelection: Boolean) : Commands data class SearchQueryChange(val query: String) : Commands data class SearchFocusChange(val focus: Boolean) : Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index 09428ee146..dd3bd32284 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -139,7 +139,8 @@ fun RadioMemberItem( showProBadge: Boolean, modifier: Modifier = Modifier, subtitle: String? = null, - subtitleColor: Color = LocalColors.current.textSecondary + subtitleColor: Color = LocalColors.current.textSecondary, + showRadioButton: Boolean = true ) { MemberItem( address = address, @@ -152,10 +153,12 @@ fun RadioMemberItem( showProBadge = showProBadge, modifier = modifier ) { - RadioButtonIndicator( - selected = selected, - enabled = enabled - ) + if (showRadioButton) { + RadioButtonIndicator( + selected = selected, + enabled = enabled + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt index 7b69716593..af8317d772 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt @@ -138,6 +138,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -160,6 +161,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -182,6 +184,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) GroupMembers( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt index 614a1c7eed..57af934265 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.groups.compose +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -29,10 +30,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -50,6 +53,7 @@ import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.LoadingDialog import org.thoughtcrime.securesms.ui.SearchBarWithClose import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -95,7 +99,7 @@ fun ManageAdmins( val handleBack: () -> Unit = { when { - searchFocused -> sendCommand(ManageGroupAdminsViewModel.Commands.RemoveSearchState(false)) + searchFocused -> sendCommand(RemoveSearchState(false)) else -> onBack() } } @@ -221,7 +225,10 @@ fun ManageAdmins( ManageMemberItem( modifier = Modifier.fillMaxWidth(), member = member, - onClick = { sendCommand(MemberClick(member)) }, + onClick = { + if (member.isSelf) sendCommand(SelfClick) + else sendCommand(MemberClick(member)) + }, selected = member in selectedMembers ) } @@ -234,6 +241,25 @@ fun ManageAdmins( } } } + + if (uiState.inProgress) { + LoadingDialog() + } + + val context = LocalContext.current + + LaunchedEffect(showingError) { + if (showingError != null) { + Toast.makeText(context, showingError, Toast.LENGTH_SHORT).show() + sendCommand(DismissError) + } + } + LaunchedEffect(showingOngoingAction) { + if (showingOngoingAction != null) { + Toast.makeText(context, showingOngoingAction, Toast.LENGTH_SHORT).show() + sendCommand(DismissResend) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt index 6c8b49ea59..f3eea0d0e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -310,7 +310,8 @@ fun ManageMemberItem( onClick = onClick, modifier = modifier, enabled = true, - selected = selected + selected = selected, + showRadioButton = !member.isSelf ) } @@ -414,6 +415,7 @@ private fun EditGroupPreviewSheet() { showProBadge = true, clickable = true, statusLabel = "Invited", + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -435,7 +437,8 @@ private fun EditGroupPreviewSheet() { showAsAdmin = true, showProBadge = true, clickable = true, - statusLabel = "Promotion failed" + statusLabel = "Promotion failed", + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -457,7 +460,8 @@ private fun EditGroupPreviewSheet() { showAsAdmin = false, showProBadge = false, clickable = true, - statusLabel = "" + statusLabel = "", + isSelf = true ) val (_, _) = remember { mutableStateOf(null) } @@ -510,6 +514,7 @@ private fun EditGroupEditNamePreview( showProBadge = true, clickable = true, statusLabel = "Invited", + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -531,7 +536,8 @@ private fun EditGroupEditNamePreview( showAsAdmin = true, showProBadge = true, clickable = true, - statusLabel = "Promotion failed" + statusLabel = "Promotion failed", + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -553,7 +559,8 @@ private fun EditGroupEditNamePreview( showAsAdmin = false, showProBadge = false, clickable = true, - statusLabel = "" + statusLabel = "", + isSelf = false ) ManageMembers( From 694134d3fd6588ef600cc66af770991cd74eb005 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 18 Nov 2025 12:48:43 +0800 Subject: [PATCH 03/23] Promote members viewmodel --- .../groups/ManageGroupAdminsViewModel.kt | 20 +- .../groups/PromoteMembersViewModel.kt | 260 ++++++++++++++++++ .../groups/compose/PromoteMembersScreen.kt | 7 + 3 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index ea129be22c..68a787b222 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -58,16 +58,6 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( ) { private val groupId = groupAddress.accountId - // Current group name (for header / text, if needed) - val groupName: StateFlow = groupInfo - .map { it?.first?.name.orEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") - - private val _mutableSelectedAdmins = MutableStateFlow(emptySet()) - val selectedAdmins: StateFlow> = _mutableSelectedAdmins - - private val footerCollapsed = MutableStateFlow(false) - /** * One option for admins for now: "Promote members" */ @@ -82,6 +72,16 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( ) } + // Current group name (for header / text, if needed) + val groupName: StateFlow = groupInfo + .map { it?.first?.name.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + + private val _mutableSelectedAdmins = MutableStateFlow(emptySet()) + val selectedAdmins: StateFlow> = _mutableSelectedAdmins + + private val footerCollapsed = MutableStateFlow(false) + private val _uiState = MutableStateFlow(UiState(options = optionsList)) val uiState: StateFlow = _uiState diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt new file mode 100644 index 0000000000..15f4a5924e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -0,0 +1,260 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.util.AvatarUtils + +@HiltViewModel(assistedFactory = PromoteMembersViewModel.Factory::class) +class PromoteMembersViewModel( + @Assisted private val groupAddress: Address.Group, + @ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val groupManager: GroupManagerV2, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : BaseGroupMembersViewModel( + groupAddress = groupAddress, + context = context, + storage = storage, + configFactory = configFactory, + avatarUtils = avatarUtils, + recipientRepository = recipientRepository +) { + private val groupId = groupAddress.accountId + + // Current group name (for header / text, if needed) + val groupName: StateFlow = groupInfo + .map { it?.first?.name.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + + private val _mutableSelectedMembers = MutableStateFlow(emptySet()) + val selectedMembers: StateFlow> = _mutableSelectedMembers + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + private val _footerCollapsed = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine( + selectedMembers, + _footerCollapsed, + ::buildFooterState + ).collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + viewModelScope.launch { + selectedMembers + .map { selected -> buildPromoteDialogBody(selected) } + .collect { body -> + _uiState.update { it.copy(promoteDialogBody = body) } + } + } + } + + fun onMemberItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedMembers.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) + } + _mutableSelectedMembers.value = newSet + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + fun toggleFooter() { + _footerCollapsed.update { !it } + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun clearSelection() { + _mutableSelectedMembers.value = emptySet() + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + + val title = + if (count == 0) GetString("") + else { + GetString( + context.resources.getQuantityString( + R.plurals.memberSelected, + count, + count + ) + ) + } + + val footerAction = GetString( + context.resources.getQuantityString( + R.plurals.promoteMember, + count, count + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerTitle = title, + footerActionLabel = footerAction + ) + } + + private fun buildPromoteDialogBody( + selected: Set + ): CharSequence { + val count = selected.size + val firstMember = selected.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> { + Phrase.from(context, R.string.adminPromoteDescription) + .put(NAME_KEY, firstMember?.name) + .format() + } + 2 -> { + val secondMember = selected.elementAtOrNull(1)?.name + Phrase.from(context, R.string.adminPromoteTwoDescription) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.adminPromoteMoreDescription) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .format() + } + + return body + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowPromoteDialog -> { + _uiState.update { it.copy(showPromoteDialog = true) } + } + + is Commands.DismissPromoteDialog -> { + _uiState.update { it.copy(showPromoteDialog = false) } + } + + is Commands.ShowConfirmDialog -> { + _uiState.update { it.copy(showConfirmDialog = true) } + } + + is Commands.DismissConfirmDialog -> { + _uiState.update { it.copy(showConfirmDialog = false) } + } + + is Commands.ToggleFooter -> toggleFooter() + + is Commands.CloseFooter, + is Commands.ClearSelection -> clearSelection() + + is Commands.MemberClick -> onMemberItemClicked(command.member) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + sealed interface Commands { + data object ShowPromoteDialog : Commands + data object DismissPromoteDialog : Commands + + data object ShowConfirmDialog : Commands + data object DismissConfirmDialog : Commands + + data object ToggleFooter : Commands + data object CloseFooter : Commands + data object ClearSelection : Commands + + class RemoveSearchState(val clearSelection: Boolean) : Commands + data class SearchQueryChange(val query: String) : Commands + data class SearchFocusChange(val focus: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + data class UiState( + val error: String? = null, + val ongoingAction: String? = null, + + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + val showConfirmDialog: Boolean = false, + + val showPromoteDialog: Boolean = false, + val promoteDialogBody: CharSequence = "", + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerTitle: GetString = GetString(""), + val footerActionLabel: GetString = GetString("") + ) + + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + navigator: UINavigator + ): ManageGroupAdminsViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt new file mode 100644 index 0000000000..3018875498 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.compose.runtime.Composable + +@Composable +fun PromoteMembersScreen() { +} \ No newline at end of file From 6517e4d85d02fe307bc4ddcb32e30bad9bf727ff Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 18 Nov 2025 14:32:58 +0800 Subject: [PATCH 04/23] Manage admins and promote admins screens --- .../settings/ConversationSettingsNavHost.kt | 42 +++++ .../groups/ManageGroupAdminsViewModel.kt | 2 +- .../groups/PromoteMembersViewModel.kt | 13 +- .../securesms/groups/compose/Components.kt | 28 +++ .../compose/ManageGroupMembersScreen.kt | 27 --- .../groups/compose/PromoteMembersScreen.kt | 170 +++++++++++++++++- 6 files changed, 244 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index bfe6dbfa6d..4142d7677d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -32,11 +32,13 @@ import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen import org.thoughtcrime.securesms.groups.compose.InviteAccountIdScreen import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen import org.thoughtcrime.securesms.groups.compose.ManageGroupAdminsScreen +import org.thoughtcrime.securesms.groups.compose.PromoteMembersScreen import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.media.MediaOverviewScreen @@ -83,6 +85,16 @@ sealed interface ConversationSettingsDestination: Parcelable { val groupAddress: Address.Group get() = Address.Group(AccountId(address)) } + @Serializable + @Parcelize + data class RoutePromoteMembers private constructor( + private val address: String + ): ConversationSettingsDestination { + constructor(groupAddress: Address.Group): this(groupAddress.address) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } + @Serializable @Parcelize data class RouteInviteToGroup private constructor( @@ -365,6 +377,36 @@ fun ConversationSettingsNavHost( ) } + // Promote Members to group Admin + horizontalSlideComposable { backStackEntry -> + val data: RoutePromoteMembers = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(groupAddress = data.groupAddress) + } + + // grab a hold of manage group's VM + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry( + RouteManageAdmins(data.groupAddress) + ) + } + val manageGroupAdminsViewModel: ManageGroupAdminsViewModel = hiltViewModel(parentEntry) + + PromoteMembersScreen( + viewModel = viewModel, + onConfirmClick = { -> + //send invites from the manage admin screen +// manageGroupAdminsViewModel.onResendPromotionsClicked(viewModel.selectedMembers) +// handleBack() + }, + onBack = dropUnlessResumed { + handleBack() + }, + ) + } + // Disappearing Messages horizontalSlideComposable { val viewModel: DisappearingMessagesViewModel = diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index 68a787b222..f25da7ced4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -113,7 +113,7 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( private fun navigateToPromoteMembers() { viewModelScope.launch { navigator.navigate( - ConversationSettingsDestination.RouteManageMembers(groupAddress) + ConversationSettingsDestination.RoutePromoteMembers(groupAddress) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index 15f4a5924e..e68b8deaf3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -23,14 +24,12 @@ import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.AvatarUtils @HiltViewModel(assistedFactory = PromoteMembersViewModel.Factory::class) -class PromoteMembersViewModel( +class PromoteMembersViewModel @AssistedInject constructor( @Assisted private val groupAddress: Address.Group, @ApplicationContext private val context: Context, storage: StorageProtocol, @@ -218,7 +217,7 @@ class PromoteMembersViewModel( data object CloseFooter : Commands data object ClearSelection : Commands - class RemoveSearchState(val clearSelection: Boolean) : Commands + data class RemoveSearchState(val clearSelection: Boolean) : Commands data class SearchQueryChange(val query: String) : Commands data class SearchFocusChange(val focus: Boolean) : Commands @@ -226,9 +225,6 @@ class PromoteMembersViewModel( } data class UiState( - val error: String? = null, - val ongoingAction: String? = null, - // search UI state: val searchQuery: String = "", val isSearchFocused: Boolean = false, @@ -254,7 +250,6 @@ class PromoteMembersViewModel( interface Factory { fun create( groupAddress: Address.Group, - navigator: UINavigator - ): ManageGroupAdminsViewModel + ): PromoteMembersViewModel } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index dd3bd32284..2c11a16dbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonData @@ -242,6 +243,33 @@ fun InviteMembersDialog( ) } +@Composable +fun ManageMemberItem( + member: GroupMemberState, + onClick: (address: Address) -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false +) { + RadioMemberItem( + address = Address.fromSerialized(member.accountId.hexString), + title = member.name, + subtitle = member.statusLabel, + subtitleColor = if (member.highlightStatus) { + LocalColors.current.danger + } else { + LocalColors.current.textSecondary + }, + showAsAdmin = member.showAsAdmin, + showProBadge = member.showProBadge, + avatarUIData = member.avatarUIData, + onClick = onClick, + modifier = modifier, + enabled = true, + selected = selected, + showRadioButton = !member.isSelf + ) +} + @Preview @Composable fun PreviewMemberList() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt index f3eea0d0e3..f8026be0cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -288,33 +288,6 @@ fun ManageMembers( } } -@Composable -fun ManageMemberItem( - member: GroupMemberState, - onClick: (address: Address) -> Unit, - modifier: Modifier = Modifier, - selected: Boolean = false -) { - RadioMemberItem( - address = Address.fromSerialized(member.accountId.hexString), - title = member.name, - subtitle = member.statusLabel, - subtitleColor = if (member.highlightStatus) { - LocalColors.current.danger - } else { - LocalColors.current.textSecondary - }, - showAsAdmin = member.showAsAdmin, - showProBadge = member.showProBadge, - avatarUIData = member.avatarUIData, - onClick = onClick, - modifier = modifier, - enabled = true, - selected = selected, - showRadioButton = !member.isSelf - ) -} - @Composable fun ShowRemoveMembersDialog( state: ManageGroupMembersViewModel.RemoveMembersDialogState, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt index 3018875498..3e51477750 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -1,7 +1,175 @@ package org.thoughtcrime.securesms.groups.compose +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import network.loki.messenger.R +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.* +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType @Composable -fun PromoteMembersScreen() { +fun PromoteMembersScreen( + viewModel: PromoteMembersViewModel, + onConfirmClick: () -> Unit, + onBack: () -> Unit, +) { + val uiState = viewModel.uiState.collectAsState().value + val searchQuery = viewModel.searchQuery.collectAsState().value + val members = viewModel.nonAdminMembers.collectAsState().value + val selectedMembers = viewModel.selectedMembers.collectAsState().value + + PromoteMembers( + onBack = onBack, + uiState = uiState, + searchQuery = searchQuery, + sendCommand = viewModel::onCommand, + members = members, + selectedMembers = selectedMembers, + onConfirmClick = onConfirmClick + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromoteMembers( + onBack: () -> Unit, + uiState: PromoteMembersViewModel.UiState, + searchQuery: String, + sendCommand: (command: Commands) -> Unit, + members: List, + selectedMembers: Set = emptySet(), + onConfirmClick: () -> Unit +) { + val searchFocused = uiState.isSearchFocused + + val handleBack: () -> Unit = { + when { + searchFocused -> sendCommand(Commands.RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + + + Scaffold( + topBar = { + BackAppBar( + title = pluralStringResource(id = R.plurals.promoteMember,2), + onBack = handleBack, + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = uiState.footer.footerTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = emptyList() + ), + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } + ) + } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(R.string.adminCannotBeDemoted), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(members) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { sendCommand(MemberClick(member)) }, + selected = member in selectedMembers + ) + } + + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } + } } \ No newline at end of file From e4b0dc186427bba5484b84721e0db492683cd583 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 18 Nov 2025 17:21:38 +0800 Subject: [PATCH 05/23] Ui fixes --- .../groups/BaseGroupMembersViewModel.kt | 10 + .../groups/PromoteMembersViewModel.kt | 6 +- .../groups/compose/ManageGroupAdminsScreen.kt | 45 +++-- .../compose/ManageGroupMembersScreen.kt | 5 +- .../groups/compose/PromoteMembersScreen.kt | 181 ++++++++++++++---- 5 files changed, 187 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 7b9cb304ad..7006737a32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -94,6 +94,16 @@ abstract class BaseGroupMembersViewModel( .map { list -> list.filter { !it.showAsAdmin } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + // Output : List of active members that can be promoted + val activeMembers: StateFlow> = members + .map { list -> list.filter { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val hasActiveMembers: StateFlow = + groupInfo + .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED} } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + val hasNonAdminMembers: StateFlow = groupInfo .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index e68b8deaf3..559c6653eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -145,7 +145,7 @@ class PromoteMembersViewModel @AssistedInject constructor( private fun buildPromoteDialogBody( selected: Set - ): CharSequence { + ): String { val count = selected.size val firstMember = selected.firstOrNull() @@ -170,7 +170,7 @@ class PromoteMembersViewModel @AssistedInject constructor( .format() } - return body + return body.toString() } fun onCommand(command: Commands) { @@ -232,7 +232,7 @@ class PromoteMembersViewModel @AssistedInject constructor( val showConfirmDialog: Boolean = false, val showPromoteDialog: Boolean = false, - val promoteDialogBody: CharSequence = "", + val promoteDialogBody: String = "", //Collapsible footer val footer: CollapsibleFooterState = CollapsibleFooterState() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt index 57af934265..a421bb4edf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt @@ -150,6 +150,7 @@ fun ManageAdmins( style = LocalType.current.base, color = LocalColors.current.textSecondary ) + AnimatedVisibility( // show only when add-members is enabled AND search is not focused visible = !searchFocused, @@ -164,31 +165,37 @@ fun ManageAdmins( shrinkTowards = Alignment.Top ) ) { - Cell( - modifier = Modifier - .fillMaxWidth() - .padding(LocalDimensions.current.smallSpacing), - ) { - Column { - uiState.options.forEachIndexed { index, option -> - ItemButton( - modifier = Modifier.qaTag(option.qaTag), - text = annotatedStringResource(option.name), - iconRes = option.icon, - shape = when (index) { - 0 -> getCellTopShape() - uiState.options.lastIndex -> getCellBottomShape() - else -> RectangleShape - }, - onClick = option.onClick, - ) + Column { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalDimensions.current.smallSpacing), + ) { + Column { + uiState.options.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + uiState.options.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick, + ) - if (index != uiState.options.lastIndex) Divider() + if (index != uiState.options.lastIndex) Divider() + } } } } } + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + if (!searchFocused) { Text( modifier = Modifier.padding( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt index f8026be0cd..42ce37ff07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import network.loki.messenger.R import network.loki.messenger.libsession_util.util.GroupMember -import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel @@ -262,7 +261,7 @@ fun ManageMembers( } if (uiState.removeMembersDialog.visible) { - ShowRemoveMembersDialog( + RemoveMembersDialog( state = uiState.removeMembersDialog, sendCommand = sendCommand ) @@ -289,7 +288,7 @@ fun ManageMembers( } @Composable -fun ShowRemoveMembersDialog( +fun RemoveMembersDialog( state: ManageGroupMembersViewModel.RemoveMembersDialogState, modifier: Modifier = Modifier, sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt index 3e51477750..5954294753 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups.compose import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -26,19 +27,32 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import network.loki.messenger.R import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ShowSendInviteDialog import org.thoughtcrime.securesms.groups.PromoteMembersViewModel import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.* +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideUsernameDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.SetUsername +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.UpdateUsername +import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.SearchBarWithClose import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -51,6 +65,7 @@ fun PromoteMembersScreen( ) { val uiState = viewModel.uiState.collectAsState().value val searchQuery = viewModel.searchQuery.collectAsState().value + val hasActiveMembers = viewModel.hasActiveMembers.collectAsState().value val members = viewModel.nonAdminMembers.collectAsState().value val selectedMembers = viewModel.selectedMembers.collectAsState().value @@ -61,7 +76,8 @@ fun PromoteMembersScreen( sendCommand = viewModel::onCommand, members = members, selectedMembers = selectedMembers, - onConfirmClick = onConfirmClick + onConfirmClick = onConfirmClick, + hasActiveMembers = true ) } @@ -74,6 +90,7 @@ fun PromoteMembers( sendCommand: (command: Commands) -> Unit, members: List, selectedMembers: Set = emptySet(), + hasActiveMembers: Boolean = false, onConfirmClick: () -> Unit ) { val searchFocused = uiState.isSearchFocused @@ -92,7 +109,7 @@ fun PromoteMembers( Scaffold( topBar = { BackAppBar( - title = pluralStringResource(id = R.plurals.promoteMember,2), + title = pluralStringResource(id = R.plurals.promoteMember, 2), onBack = handleBack, ) }, @@ -108,7 +125,14 @@ fun PromoteMembers( title = uiState.footer.footerTitle, collapsed = uiState.footer.collapsed, visible = uiState.footer.visible, - items = emptyList() + items = listOf( + CollapsibleFooterItemData( + label = uiState.footer.footerActionLabel, + buttonLabel = GetString(LocalResources.current.getString(R.string.promote)), + isDanger = false, + onClick = { sendCommand(ShowPromoteDialog) } + ) + ) ), onCollapsedClicked = { sendCommand(ToggleFooter) }, onClosedClicked = { sendCommand(CloseFooter) } @@ -127,49 +151,136 @@ fun PromoteMembers( .padding(horizontal = LocalDimensions.current.mediumSpacing) .fillMaxWidth() .wrapContentWidth(Alignment.CenterHorizontally), - text = LocalResources.current.getString(R.string.adminCannotBeDemoted), + text = LocalResources.current.getString(if (!hasActiveMembers) R.string.noNonAdminsInGroup else R.string.adminCannotBeDemoted), textAlign = TextAlign.Center, style = LocalType.current.base, color = LocalColors.current.textSecondary ) - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + if (hasActiveMembers) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - SearchBarWithClose( - query = searchQuery, - onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, - onClear = { sendCommand(SearchQueryChange("")) }, - placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), - enabled = true, - isFocused = searchFocused, - modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), - onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } - ) + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - // List of members - LazyColumn( - modifier = Modifier - .weight(1f) - .imePadding() - ) { - items(members) { member -> - // Each member's view - ManageMemberItem( - modifier = Modifier.fillMaxWidth(), - member = member, - onClick = { sendCommand(MemberClick(member)) }, - selected = member in selectedMembers - ) - } + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(members) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { sendCommand(MemberClick(member)) }, + selected = member in selectedMembers + ) + } - item { - Spacer( - modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) - ) + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } } } } } + + if (uiState.showConfirmDialog) { + ConfirmDialog(onConfirmClick = onConfirmClick, sendCommand = sendCommand) + } + + if (uiState.showPromoteDialog) { + PromotionDialog(sendCommand = sendCommand, bodyText = uiState.promoteDialogBody) + } +} + +@Composable +fun ConfirmDialog( + modifier: Modifier = Modifier, + onConfirmClick: () -> Unit, + sendCommand: (Commands) -> Unit +) { + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(DismissConfirmDialog) + }, + title = annotatedStringResource(R.string.confirmPromotion), + text = annotatedStringResource(R.string.confirmPromotionDescription), + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissConfirmDialog) + } + ), + DialogButtonData( + text = GetString(stringResource(id = R.string.confirm)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissConfirmDialog) + onConfirmClick() + } + ) + ) + ) +} + +@Composable +fun PromotionDialog( + modifier: Modifier = Modifier, + sendCommand: (Commands) -> Unit, + bodyText: String +) { + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(DismissPromoteDialog) + }, + title = stringResource(R.string.promote), + text = bodyText, + showCloseButton = true, + content = { + Text( + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + text = LocalResources.current.getString(R.string.promoteAdminsWarning), + style = LocalType.current.small, + color = LocalColors.current.warning, + textAlign = TextAlign.Center + ) + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.promote)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissConfirmDialog) + sendCommand(ShowConfirmDialog) + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissConfirmDialog) + } + ) + ) + ) } \ No newline at end of file From ff2ccd1df3425308110c3f409c2ef636f451916f Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 19 Nov 2025 07:49:22 +0800 Subject: [PATCH 06/23] Sending and resending promotions --- .../settings/ConversationSettingsNavHost.kt | 4 ++-- .../groups/BaseGroupMembersViewModel.kt | 4 ++-- .../groups/ManageGroupAdminsViewModel.kt | 8 +++---- .../groups/compose/PromoteMembersScreen.kt | 23 +++++++++---------- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 4142d7677d..af2366153d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -398,8 +398,8 @@ fun ConversationSettingsNavHost( viewModel = viewModel, onConfirmClick = { -> //send invites from the manage admin screen -// manageGroupAdminsViewModel.onResendPromotionsClicked(viewModel.selectedMembers) -// handleBack() + manageGroupAdminsViewModel.onSendPromotionsClicked(false) + handleBack() }, onBack = dropUnlessResumed { handleBack() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 7006737a32..2008932546 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -96,12 +96,12 @@ abstract class BaseGroupMembersViewModel( // Output : List of active members that can be promoted val activeMembers: StateFlow> = members - .map { list -> list.filter { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } + .map { list -> list.filter { !it.showAsAdmin && it.status != GroupMember.Status.PROMOTION_ACCEPTED } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val hasActiveMembers: StateFlow = groupInfo - .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED} } + .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin} } .stateIn(viewModelScope, SharingStarted.Lazily, false) val hasNonAdminMembers: StateFlow = diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index f25da7ced4..15fd552dc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -119,9 +119,9 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( } /** - * Resend promotions to all selected admins. + * Send promotions to all selected admins. */ - fun onResendPromotionsClicked() { + fun onSendPromotionsClicked(isResend : Boolean) { val selected = selectedAdmins.value if (selected.isEmpty()) return @@ -143,7 +143,7 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( groupManager.promoteMember( groupId, accountIds, - isRepromote = true + isRepromote = isResend ) } } @@ -234,7 +234,7 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( ), buttonLabel = GetString(context.getString(R.string.resend)), isDanger = false, - onClick = { onResendPromotionsClicked() } + onClick = { onSendPromotionsClicked(true) } ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt index 5954294753..9967bc5a24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.groups.compose import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -27,31 +26,31 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import network.loki.messenger.R import org.thoughtcrime.securesms.groups.GroupMemberState -import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ShowSendInviteDialog import org.thoughtcrime.securesms.groups.PromoteMembersViewModel import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands -import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.* -import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideUsernameDialog -import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.SetUsername -import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.UpdateUsername +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.CloseFooter +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.DismissConfirmDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.DismissPromoteDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.MemberClick +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SearchFocusChange +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SearchQueryChange +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowConfirmDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowPromoteDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ToggleFooter import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.SearchBarWithClose import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton -import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -66,7 +65,7 @@ fun PromoteMembersScreen( val uiState = viewModel.uiState.collectAsState().value val searchQuery = viewModel.searchQuery.collectAsState().value val hasActiveMembers = viewModel.hasActiveMembers.collectAsState().value - val members = viewModel.nonAdminMembers.collectAsState().value + val members = viewModel.activeMembers.collectAsState().value val selectedMembers = viewModel.selectedMembers.collectAsState().value PromoteMembers( @@ -77,7 +76,7 @@ fun PromoteMembersScreen( members = members, selectedMembers = selectedMembers, onConfirmClick = onConfirmClick, - hasActiveMembers = true + hasActiveMembers = hasActiveMembers ) } From 22d04c7a070f0af67271878cf9117375a0e0a0b4 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 19 Nov 2025 09:46:11 +0800 Subject: [PATCH 07/23] Cleanup, moved operation to promote members screen --- .../settings/ConversationSettingsNavHost.kt | 13 ---- .../groups/ManageGroupMembersViewModel.kt | 6 -- .../groups/PromoteMembersViewModel.kt | 66 +++++++++++++++++++ .../groups/compose/PromoteMembersScreen.kt | 31 +++++++-- 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index af2366153d..00cae893d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -386,21 +386,8 @@ fun ConversationSettingsNavHost( factory.create(groupAddress = data.groupAddress) } - // grab a hold of manage group's VM - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry( - RouteManageAdmins(data.groupAddress) - ) - } - val manageGroupAdminsViewModel: ManageGroupAdminsViewModel = hiltViewModel(parentEntry) - PromoteMembersScreen( viewModel = viewModel, - onConfirmClick = { -> - //send invites from the manage admin screen - manageGroupAdminsViewModel.onSendPromotionsClicked(false) - handleBack() - }, onBack = dropUnlessResumed { handleBack() }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt index 9c11ef7ae8..eed16d50f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -260,12 +260,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onResendPromotionClicked(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = true) - } - } - fun onDismissError() { _uiState.update { it.copy(error = null) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index 559c6653eb..0dca48e89f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -8,6 +8,8 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -27,6 +29,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_K import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUtils +import kotlin.collections.map @HiltViewModel(assistedFactory = PromoteMembersViewModel.Factory::class) class PromoteMembersViewModel @AssistedInject constructor( @@ -109,6 +112,56 @@ class PromoteMembersViewModel @AssistedInject constructor( _mutableSelectedMembers.value = emptySet() } + fun sendPromotionInvites(){ + val selected = selectedMembers.value + if (selected.isEmpty()) return + + performGroupOperation() { + val accountIds = selected.map { it.accountId } + + removeSearchState(clearSelection = true) + + _uiState.update { + it.copy( + toast = context.resources.getQuantityString( + R.plurals.resendingInvite, + selectedMembers.value.size, + selectedMembers.value.size + ) + ) + } + + groupManager.promoteMember( + groupId, + accountIds, + isRepromote = false + ) + } + } + + private fun performGroupOperation( + errorMessage: ((Throwable) -> String?)? = null, + operation: suspend () -> Unit + ) { + viewModelScope.launch { + @Suppress("OPT_IN_USAGE") + val task = GlobalScope.async { + operation() + } + + try { + task.await() + } catch (e: Exception) { + _uiState.update { + it.copy( + toast = errorMessage?.invoke(e) + ?: context.getString(R.string.errorUnknown) + ) + } + } + } + } + private fun buildFooterState( selected: Set, isCollapsed: Boolean @@ -155,6 +208,7 @@ class PromoteMembersViewModel @AssistedInject constructor( .put(NAME_KEY, firstMember?.name) .format() } + 2 -> { val secondMember = selected.elementAtOrNull(1)?.name Phrase.from(context, R.string.adminPromoteTwoDescription) @@ -191,6 +245,12 @@ class PromoteMembersViewModel @AssistedInject constructor( _uiState.update { it.copy(showConfirmDialog = false) } } + is Commands.DismissToast -> { + _uiState.update { it.copy(toast = null) } + } + + is Commands.SendPromotionInvites -> sendPromotionInvites() + is Commands.ToggleFooter -> toggleFooter() is Commands.CloseFooter, @@ -213,6 +273,10 @@ class PromoteMembersViewModel @AssistedInject constructor( data object ShowConfirmDialog : Commands data object DismissConfirmDialog : Commands + data object DismissToast : Commands + + data object SendPromotionInvites : Commands + data object ToggleFooter : Commands data object CloseFooter : Commands data object ClearSelection : Commands @@ -225,6 +289,8 @@ class PromoteMembersViewModel @AssistedInject constructor( } data class UiState( + val toast: String? = null, + // search UI state: val searchQuery: String = "", val isSearchFocused: Boolean = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt index 9967bc5a24..810dc6a012 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.groups.compose +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,9 +24,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -43,6 +46,7 @@ import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.Search import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowConfirmDialog import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowPromoteDialog import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ToggleFooter +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SendPromotionInvites import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData @@ -59,7 +63,6 @@ import org.thoughtcrime.securesms.ui.theme.LocalType @Composable fun PromoteMembersScreen( viewModel: PromoteMembersViewModel, - onConfirmClick: () -> Unit, onBack: () -> Unit, ) { val uiState = viewModel.uiState.collectAsState().value @@ -75,7 +78,6 @@ fun PromoteMembersScreen( sendCommand = viewModel::onCommand, members = members, selectedMembers = selectedMembers, - onConfirmClick = onConfirmClick, hasActiveMembers = hasActiveMembers ) } @@ -89,8 +91,7 @@ fun PromoteMembers( sendCommand: (command: Commands) -> Unit, members: List, selectedMembers: Set = emptySet(), - hasActiveMembers: Boolean = false, - onConfirmClick: () -> Unit + hasActiveMembers: Boolean = false ) { val searchFocused = uiState.isSearchFocused @@ -199,18 +200,33 @@ fun PromoteMembers( } if (uiState.showConfirmDialog) { - ConfirmDialog(onConfirmClick = onConfirmClick, sendCommand = sendCommand) + ConfirmDialog( + sendCommand = sendCommand, + onConfirmClicked = { + sendCommand(SendPromotionInvites) + onBack() + }) } if (uiState.showPromoteDialog) { PromotionDialog(sendCommand = sendCommand, bodyText = uiState.promoteDialogBody) } + + + val context = LocalContext.current + + LaunchedEffect(uiState.toast) { + if (!uiState.toast.isNullOrEmpty()) { + Toast.makeText(context, uiState.toast, Toast.LENGTH_SHORT).show() + sendCommand(Commands.DismissToast) + } + } } @Composable fun ConfirmDialog( modifier: Modifier = Modifier, - onConfirmClick: () -> Unit, + onConfirmClicked: () -> Unit, sendCommand: (Commands) -> Unit ) { AlertDialog( @@ -234,7 +250,7 @@ fun ConfirmDialog( dismissOnClick = false, onClick = { sendCommand(DismissConfirmDialog) - onConfirmClick() + onConfirmClicked() } ) ) @@ -272,6 +288,7 @@ fun PromotionDialog( onClick = { sendCommand(DismissConfirmDialog) sendCommand(ShowConfirmDialog) + } ), DialogButtonData( From 9150cb0ed9b9f4b5a844a4f9951153e5b135ba4f Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 19 Nov 2025 13:31:06 +0800 Subject: [PATCH 08/23] initial admin leave group --- .../messaging/groups/GroupManagerV2.kt | 5 ++ .../settings/ConversationSettingsNavHost.kt | 2 +- .../settings/ConversationSettingsViewModel.kt | 52 ++++++++++++++-- .../securesms/groups/GroupManagerV2Impl.kt | 62 ++++++++++++++++++- 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index 097341f1e9..a70d297d02 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -125,11 +125,16 @@ interface GroupManagerV2 { fun getLeaveGroupConfirmationDialogData(groupId: AccountId, name: String): ConfirmDialogData? + fun getAdminLeaveGroupDialogData(groupId : AccountId, name : String) : ConfirmDialogData? + + fun isCurrentUserLastAdmin(groupId : AccountId) : Boolean + data class ConfirmDialogData( val title: String, val message: CharSequence, @StringRes val positiveText: Int, @StringRes val negativeText: Int, + @StringRes val negativePlurals : Int? = null, @StringRes val positiveQaTag: Int?, @StringRes val negativeQaTag: Int?, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 00cae893d4..a4dbfa4ef7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -87,7 +87,7 @@ sealed interface ConversationSettingsDestination: Parcelable { @Serializable @Parcelize - data class RoutePromoteMembers private constructor( + data class RoutePromoteMembers( private val address: String ): ConversationSettingsDestination { constructor(groupAddress: Address.Group): this(groupAddress.address) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index c76def6309..a64a77546a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 @@ -313,6 +314,16 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } + private val optionAdminLeaveGroup: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.groupLeave), + icon = R.drawable.ic_log_out, + qaTag = R.string.qa_conversation_settings_leave_group, + onClick = ::confirmAdminLeaveGroup + ) + } + + // Community private val optionCopyCommunityURL: OptionsItem by lazy{ OptionsItem( @@ -582,7 +593,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( dangerOptions.addAll( listOf( optionClearMessages, - optionLeaveGroup, + optionAdminLeaveGroup, optionDeleteGroup ) ) @@ -764,7 +775,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_block_confirm), negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_block_cancel), onPositive = ::blockUser, - onNegative = {} + onNegative = {}, ) ) } @@ -1025,8 +1036,37 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun confirmAdminLeaveGroup(){ + val groupV2Id = (address as? Address.Group)?.accountId ?: return + val isUserLastAdmin = groupManager.isCurrentUserLastAdmin(groupV2Id) + _dialogState.update { state -> + val dialogData = groupManager.getAdminLeaveGroupDialogData( + groupV2Id, + _uiState.value.name + ) ?: return + + state.copy( + showSimpleDialog = SimpleDialogData( + title = dialogData.title, + message = dialogData.message, + positiveText = context.getString(dialogData.positiveText), + negativeText = dialogData.negativePlurals?.let { + context.resources.getQuantityString(it, 1, 1) + } ?: context.getString(dialogData.negativeText), + positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, + onPositive = {if(isUserLastAdmin) confirmLeaveGroup() else leaveGroup()}, + onNegative = {if(isUserLastAdmin) + navigateTo(ConversationSettingsDestination.RoutePromoteMembers(groupV2Id.toString())) + } + ) + ) + } + } + private fun confirmLeaveGroup(){ val groupV2Id = (address as? Address.Group)?.accountId ?: return + _dialogState.update { state -> val dialogData = groupManager.getLeaveGroupConfirmationDialogData( groupV2Id, @@ -1038,9 +1078,11 @@ class ConversationSettingsViewModel @AssistedInject constructor( title = dialogData.title, message = dialogData.message, positiveText = context.getString(dialogData.positiveText), - negativeText = context.getString(dialogData.negativeText), - positiveQaTag = dialogData.positiveQaTag?.let{ context.getString(it) }, - negativeQaTag = dialogData.negativeQaTag?.let{ context.getString(it) }, + negativeText = dialogData.negativePlurals?.let { + context.resources.getQuantityString(it, 1, 1) + } ?: context.getString(dialogData.negativeText), + positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, onPositive = ::leaveGroup, onNegative = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 5e31d61b4a..878ebe844e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -15,6 +15,7 @@ import network.loki.messenger.R import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.Bytes.Companion.toBytes import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode @@ -1204,7 +1205,6 @@ class GroupManagerV2Impl @Inject constructor( negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel } - return GroupManagerV2.ConfirmDialogData( title = application.getString(title), message = message, @@ -1215,6 +1215,66 @@ class GroupManagerV2Impl @Inject constructor( ) } + override fun getAdminLeaveGroupDialogData( + groupId: AccountId, + name: String + ): GroupManagerV2.ConfirmDialogData? { + val groupData = configFactory.getGroup(groupId) ?: return null + + val title = R.string.groupLeave + var message: CharSequence = "" + var positiveButton = R.string.leave + var negativePlurals : Int? = null + var positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm + var negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel + + if (isCurrentUserLastAdmin(groupId)) { + message = Phrase.from(application, R.string.groupOnlyAdmin) + .put(GROUP_NAME_KEY, name) + .format() + positiveButton = R.string.groupDelete + negativePlurals = R.plurals.addAdmin + } else { + message = Phrase.from(application, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, name) + .format() + } + + return GroupManagerV2.ConfirmDialogData( + title = application.getString(title), + message = message, + positiveText = positiveButton, + negativeText = R.string.cancel, + positiveQaTag = positiveQaTag, + negativeQaTag = negativeQaTag, + negativePlurals = negativePlurals + ) + } + + override fun isCurrentUserLastAdmin(groupId: AccountId): Boolean { + val currentUserId = checkNotNull(storage.getUserPublicKey()) { "User public key is null" } + + val membersWithStatus = configFactory.withGroupConfigs(groupId) { + it.groupMembers.allWithStatus() + } + + var adminCount = 0 + var amAdmin = false + + for ((member, status) in membersWithStatus) { + val isAdminLike = member.isAdminOrBeingPromoted(status) && !member.isRemoved(status) + if (!isAdminLike) continue + + adminCount++ + + if (member.accountId() == currentUserId) { + amAdmin = true + } + } + + return amAdmin && adminCount == 1 + } + private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) { val firstError = this.results.firstOrNull { it.code != 200 } require(firstError == null) { "$errorMessage: ${firstError!!.body}" } From 4791585a80d67d67656d5e8c82d5efbd78c8a482 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 19 Nov 2025 13:45:10 +0800 Subject: [PATCH 09/23] Fix crash on navigation --- .../conversation/v2/settings/ConversationSettingsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index a64a77546a..9cf91f3e96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -1057,7 +1057,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, onPositive = {if(isUserLastAdmin) confirmLeaveGroup() else leaveGroup()}, onNegative = {if(isUserLastAdmin) - navigateTo(ConversationSettingsDestination.RoutePromoteMembers(groupV2Id.toString())) + navigateTo(ConversationSettingsDestination.RoutePromoteMembers(address)) } ) ) From 129fbc2f39fc199007144b17f7483cd2c7c093bb Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 19 Nov 2025 14:30:36 +0800 Subject: [PATCH 10/23] Updated filtering for admins and members that can be promoted --- .../groups/BaseGroupMembersViewModel.kt | 23 +++++++++++++++++-- .../securesms/groups/GroupManagerV2Impl.kt | 2 +- .../groups/compose/PromoteMembersScreen.kt | 4 ++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 2008932546..716114f992 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -96,7 +96,7 @@ abstract class BaseGroupMembersViewModel( // Output : List of active members that can be promoted val activeMembers: StateFlow> = members - .map { list -> list.filter { !it.showAsAdmin && it.status != GroupMember.Status.PROMOTION_ACCEPTED } } + .map { list -> list.filter { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val hasActiveMembers: StateFlow = @@ -111,7 +111,14 @@ abstract class BaseGroupMembersViewModel( // Output: List of only ADMINS val adminMembers: StateFlow> = members - .map { list -> list.filter { it.showAsAdmin } } + .map { list -> + list.filter { it.showAsAdmin } + .sortedWith( + compareBy { adminOrder(it) } + .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + .thenBy { it.accountId } + ) + } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) fun onSearchQueryChanged(query: String) { @@ -227,6 +234,18 @@ private fun stateOrder(status: GroupMember.Status?): Int = when (status) { else -> 6 } +private fun adminOrder(state: GroupMemberState): Int { + if (state.isSelf) return 7 // "You" always last + return when (state.status) { + GroupMember.Status.PROMOTION_FAILED -> 1 + GroupMember.Status.PROMOTION_NOT_SENT -> 2 + GroupMember.Status.PROMOTION_UNKNOWN -> 3 + GroupMember.Status.PROMOTION_SENDING -> 4 + GroupMember.Status.PROMOTION_SENT -> 5 + else -> 6 + } +} + data class GroupMemberState( val accountId: AccountId, val avatarUIData: AvatarUIData, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 878ebe844e..da49fd65bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -1262,7 +1262,7 @@ class GroupManagerV2Impl @Inject constructor( var amAdmin = false for ((member, status) in membersWithStatus) { - val isAdminLike = member.isAdminOrBeingPromoted(status) && !member.isRemoved(status) + val isAdminLike = status == GroupMember.Status.PROMOTION_ACCEPTED && !member.isRemoved(status) if (!isAdminLike) continue adminCount++ diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt index 810dc6a012..4fc50b082e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -286,7 +286,7 @@ fun PromotionDialog( color = LocalColors.current.danger, dismissOnClick = false, onClick = { - sendCommand(DismissConfirmDialog) + sendCommand(DismissPromoteDialog) sendCommand(ShowConfirmDialog) } @@ -294,7 +294,7 @@ fun PromotionDialog( DialogButtonData( text = GetString(stringResource(R.string.cancel)), onClick = { - sendCommand(DismissConfirmDialog) + sendCommand(DismissPromoteDialog) } ) ) From 8dff1995764cf041dca62309b6c83448dbcefd30 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 20 Nov 2025 12:10:11 +0800 Subject: [PATCH 11/23] option update qaTag --- .../conversation/v2/settings/ConversationSettingsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 9cf91f3e96..d7f6dbcb7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -287,7 +287,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( OptionsItem( name = context.getString(R.string.manageAdmins), icon = R.drawable.ic_add_admin_custom, - qaTag = R.string.qa_conversation_settings_manage_members, + qaTag = R.string.qa_conversation_settings_manage_admins, onClick = { (address as? Address.Group)?.let { navigateTo(ConversationSettingsDestination.RouteManageAdmins(it)) From 63cf2672a6dd4661e8935942f42edb07dad2f03c Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 20 Nov 2025 13:50:31 +0800 Subject: [PATCH 12/23] Updated toast, added negative color for negative dialog button --- .../messaging/groups/GroupManagerV2.kt | 1 - .../settings/ConversationSettingsDialogs.kt | 2 + .../settings/ConversationSettingsViewModel.kt | 22 ++++---- .../securesms/groups/GroupManagerV2Impl.kt | 11 ++-- .../groups/ManageGroupAdminsViewModel.kt | 53 +++++++------------ .../groups/compose/ManageGroupAdminsScreen.kt | 17 ------ .../thoughtcrime/securesms/ui/AlertDialog.kt | 2 + .../src/main/res/values/strings.xml | 1 + 8 files changed, 39 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index a70d297d02..2ac915e8b3 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -134,7 +134,6 @@ interface GroupManagerV2 { val message: CharSequence, @StringRes val positiveText: Int, @StringRes val negativeText: Int, - @StringRes val negativePlurals : Int? = null, @StringRes val positiveQaTag: Int?, @StringRes val negativeQaTag: Int?, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index ffc8c05635..f5bdc403d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -80,6 +80,8 @@ fun ConversationSettingsDialogs( buttons.add( DialogButtonData( text = GetString(dialogsState.showSimpleDialog.negativeText), + color = if (dialogsState.showSimpleDialog.negativeStyleDanger) LocalColors.current.danger + else LocalColors.current.text, qaTag = dialogsState.showSimpleDialog.negativeQaTag, onClick = dialogsState.showSimpleDialog.onNegative ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index d7f6dbcb7d..96a610a57f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -1050,15 +1050,19 @@ class ConversationSettingsViewModel @AssistedInject constructor( title = dialogData.title, message = dialogData.message, positiveText = context.getString(dialogData.positiveText), - negativeText = dialogData.negativePlurals?.let { - context.resources.getQuantityString(it, 1, 1) - } ?: context.getString(dialogData.negativeText), + negativeText = context.getString(dialogData.negativeText), positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, - onPositive = {if(isUserLastAdmin) confirmLeaveGroup() else leaveGroup()}, - onNegative = {if(isUserLastAdmin) - navigateTo(ConversationSettingsDestination.RoutePromoteMembers(address)) - } + onPositive = { + if (isUserLastAdmin) + navigateTo(ConversationSettingsDestination.RoutePromoteMembers(address)) + else leaveGroup() + }, + positiveStyleDanger = !isUserLastAdmin, + onNegative = { + if (isUserLastAdmin) confirmLeaveGroup() + }, + negativeStyleDanger = isUserLastAdmin // red color on the right ) ) } @@ -1078,9 +1082,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( title = dialogData.title, message = dialogData.message, positiveText = context.getString(dialogData.positiveText), - negativeText = dialogData.negativePlurals?.let { - context.resources.getQuantityString(it, 1, 1) - } ?: context.getString(dialogData.negativeText), + negativeText = context.getString(dialogData.negativeText), positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, onPositive = ::leaveGroup, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index da49fd65bc..ed75a948ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -1219,12 +1219,10 @@ class GroupManagerV2Impl @Inject constructor( groupId: AccountId, name: String ): GroupManagerV2.ConfirmDialogData? { - val groupData = configFactory.getGroup(groupId) ?: return null - val title = R.string.groupLeave var message: CharSequence = "" var positiveButton = R.string.leave - var negativePlurals : Int? = null + var negativeButton = R.string.cancel var positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm var negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel @@ -1232,8 +1230,8 @@ class GroupManagerV2Impl @Inject constructor( message = Phrase.from(application, R.string.groupOnlyAdmin) .put(GROUP_NAME_KEY, name) .format() - positiveButton = R.string.groupDelete - negativePlurals = R.plurals.addAdmin + positiveButton = R.string.addAdmins + negativeButton = R.string.groupDelete } else { message = Phrase.from(application, R.string.groupLeaveDescription) .put(GROUP_NAME_KEY, name) @@ -1244,10 +1242,9 @@ class GroupManagerV2Impl @Inject constructor( title = application.getString(title), message = message, positiveText = positiveButton, - negativeText = R.string.cancel, + negativeText = negativeButton, positiveQaTag = positiveQaTag, negativeQaTag = negativeQaTag, - negativePlurals = negativePlurals ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index 15fd552dc7..5d3ff1dc67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.groups import android.content.Context +import android.widget.Toast import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.lifecycle.viewModelScope @@ -130,15 +131,13 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( removeSearchState(clearSelection = true) - _uiState.update { - it.copy( - ongoingAction = context.resources.getQuantityString( - R.plurals.resendingPromotion, - accountIds.size, - accountIds.size - ) - ) - } + val resendingString = context.resources.getQuantityString( + R.plurals.resendingPromotion, + accountIds.size, + accountIds.size + ) + + showToast(resendingString) groupManager.promoteMember( groupId, @@ -165,14 +164,6 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( footerCollapsed.update { !it } } - fun onDismissError() { - _uiState.update { it.copy(error = null) } - } - - fun onDismissResend() { - _uiState.update { it.copy(ongoingAction = null) } - } - /** * Shared helper for group operations (same pattern with ManageGroupMembersViewModel). */ @@ -194,12 +185,9 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( try { task.await() } catch (e: Exception) { - _uiState.update { - it.copy( - error = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) - ) - } + val error = errorMessage?.invoke(e) + ?: context.getString(R.string.errorUnknown) + showToast(error) } finally { if (showLoading) { _uiState.update { it.copy(inProgress = false) } @@ -248,16 +236,10 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( fun onCommand(command: Commands) { when (command) { - is Commands.DismissError -> onDismissError() - is Commands.DismissResend -> onDismissResend() is Commands.ToggleFooter -> toggleFooter() is Commands.CloseFooter, is Commands.ClearSelection -> clearSelection() - is Commands.SelfClick -> { - _uiState.update { - it.copy(error = context.getString(R.string.adminStatusYou)) - } - } + is Commands.SelfClick -> showToast(context.getString(R.string.adminStatusYou)) is Commands.MemberClick -> onAdminItemClicked(command.member) is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) @@ -265,12 +247,16 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( } } + private fun showToast(text: String) { + Toast.makeText( + context, text, Toast.LENGTH_SHORT + ).show() + } + data class UiState( val options: List = emptyList(), val inProgress: Boolean = false, - val error: String? = null, - val ongoingAction: String? = null, // search UI state: val searchQuery: String = "", @@ -295,9 +281,6 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( ) sealed interface Commands { - data object DismissError : Commands - data object DismissResend : Commands - data object ToggleFooter : Commands data object CloseFooter : Commands data object ClearSelection : Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt index a421bb4edf..d1e0f2953d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt @@ -94,8 +94,6 @@ fun ManageAdmins( ) { val searchFocused = uiState.isSearchFocused - val showingError = uiState.error - val showingOngoingAction = uiState.ongoingAction val handleBack: () -> Unit = { when { @@ -252,21 +250,6 @@ fun ManageAdmins( if (uiState.inProgress) { LoadingDialog() } - - val context = LocalContext.current - - LaunchedEffect(showingError) { - if (showingError != null) { - Toast.makeText(context, showingError, Toast.LENGTH_SHORT).show() - sendCommand(DismissError) - } - } - LaunchedEffect(showingOngoingAction) { - if (showingOngoingAction != null) { - Toast.makeText(context, showingOngoingAction, Toast.LENGTH_SHORT).show() - sendCommand(DismissResend) - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index 4a10bee2e7..d50e4c618b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -71,6 +71,8 @@ data class SimpleDialogData( val message: CharSequence, val positiveText: String? = null, val positiveStyleDanger: Boolean = true, + + val negativeStyleDanger: Boolean = false, val showXIcon: Boolean = false, val negativeText: String? = null, val positiveQaTag: String? = null, diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index e86504c76a..9c2492ce20 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -149,6 +149,7 @@ delete-group-confirm-button delete-group-cancel-button leave-group-confirm-button + add-admin-button leave-group-cancel-button clear-all-messages-confirm-button clear-all-messages-cancel-button From f64e7daa04fbc25e6bab07c3432d9bcf5492ffd2 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 20 Nov 2025 13:53:05 +0800 Subject: [PATCH 13/23] Simplified toasts --- .../groups/PromoteMembersViewModel.kt | 37 +++++++------------ .../groups/compose/PromoteMembersScreen.kt | 10 ----- 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index 0dca48e89f..a2ca6d041f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.groups import android.content.Context +import android.widget.Toast import androidx.lifecycle.viewModelScope import com.squareup.phrase.Phrase import dagger.assisted.Assisted @@ -121,15 +122,13 @@ class PromoteMembersViewModel @AssistedInject constructor( removeSearchState(clearSelection = true) - _uiState.update { - it.copy( - toast = context.resources.getQuantityString( - R.plurals.resendingInvite, - selectedMembers.value.size, - selectedMembers.value.size - ) - ) - } + val promoteText = context.resources.getQuantityString( + R.plurals.resendingInvite, + selectedMembers.value.size, + selectedMembers.value.size + ) + + showToast(promoteText) groupManager.promoteMember( groupId, @@ -152,16 +151,16 @@ class PromoteMembersViewModel @AssistedInject constructor( try { task.await() } catch (e: Exception) { - _uiState.update { - it.copy( - toast = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) - ) - } + showToast(errorMessage?.invoke(e) + ?: context.getString(R.string.errorUnknown)) } } } + private fun showToast(text : String){ + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + } + private fun buildFooterState( selected: Set, isCollapsed: Boolean @@ -245,10 +244,6 @@ class PromoteMembersViewModel @AssistedInject constructor( _uiState.update { it.copy(showConfirmDialog = false) } } - is Commands.DismissToast -> { - _uiState.update { it.copy(toast = null) } - } - is Commands.SendPromotionInvites -> sendPromotionInvites() is Commands.ToggleFooter -> toggleFooter() @@ -273,8 +268,6 @@ class PromoteMembersViewModel @AssistedInject constructor( data object ShowConfirmDialog : Commands data object DismissConfirmDialog : Commands - data object DismissToast : Commands - data object SendPromotionInvites : Commands data object ToggleFooter : Commands @@ -289,8 +282,6 @@ class PromoteMembersViewModel @AssistedInject constructor( } data class UiState( - val toast: String? = null, - // search UI state: val searchQuery: String = "", val isSearchFocused: Boolean = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt index 4fc50b082e..3fc9f97cd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -211,16 +211,6 @@ fun PromoteMembers( if (uiState.showPromoteDialog) { PromotionDialog(sendCommand = sendCommand, bodyText = uiState.promoteDialogBody) } - - - val context = LocalContext.current - - LaunchedEffect(uiState.toast) { - if (!uiState.toast.isNullOrEmpty()) { - Toast.makeText(context, uiState.toast, Toast.LENGTH_SHORT).show() - sendCommand(Commands.DismissToast) - } - } } @Composable From b6b3fb43edb63f3513a749f180ccc4941b4a36bc Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 20 Nov 2025 14:16:27 +0800 Subject: [PATCH 14/23] dismiss before showing the new dialog --- app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index d50e4c618b..28ae506379 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -213,8 +213,8 @@ fun AlertDialogContent( color = it.color, enabled = it.enabled ) { - it.onClick() if (it.dismissOnClick) onDismissRequest() + it.onClick() } } } From 2f3723c0a31996b4efce44c7ea8e0e60d19cf549 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 20 Nov 2025 15:20:53 +0800 Subject: [PATCH 15/23] moved showToast to base viewmodel --- .../groups/BaseGroupMembersViewModel.kt | 7 ++ .../groups/ManageGroupAdminsViewModel.kt | 10 +-- .../groups/ManageGroupMembersViewModel.kt | 76 ++++++------------- .../groups/PromoteMembersViewModel.kt | 4 - .../compose/ManageGroupMembersScreen.kt | 17 ----- 5 files changed, 32 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 716114f992..c7a859b5e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.groups import android.content.Context +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.qualifiers.ApplicationContext @@ -213,6 +214,12 @@ abstract class BaseGroupMembersViewModel( .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) .thenBy { it.accountId } ) + + fun showToast(text: String) { + Toast.makeText( + context, text, Toast.LENGTH_SHORT + ).show() + } } private fun stateOrder(status: GroupMember.Status?): Int = when (status) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index 5d3ff1dc67..36f3b9f64a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -131,13 +131,13 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( removeSearchState(clearSelection = true) - val resendingString = context.resources.getQuantityString( + val resendingText = context.resources.getQuantityString( R.plurals.resendingPromotion, accountIds.size, accountIds.size ) - showToast(resendingString) + showToast(resendingText) groupManager.promoteMember( groupId, @@ -247,12 +247,6 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( } } - private fun showToast(text: String) { - Toast.makeText( - context, text, Toast.LENGTH_SHORT - ).show() - } - data class UiState( val options: List = emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt index eed16d50f3..8f9d257a41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -155,15 +155,13 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } fun onSendInviteClicked(contacts: Set
, shareHistory : Boolean) { - _uiState.update { - it.copy( - ongoingAction = context.resources.getQuantityString( - R.plurals.groupInviteSending, - contacts.size, - contacts.size - ) - ) - } + val sendInviteText = context.resources.getQuantityString( + R.plurals.groupInviteSending, + contacts.size, + contacts.size + ) + + showToast(sendInviteText) performGroupOperation( showLoading = false, @@ -207,13 +205,13 @@ class ManageGroupMembersViewModel @AssistedInject constructor( removeSearchState(true) - _uiState.update { it -> - it.copy(error = context.resources.getQuantityString( - R.plurals.resendingInvite, - invites.size, - invites.size - )) - } + val errorText = context.resources.getQuantityString( + R.plurals.resendingInvite, + invites.size, + invites.size + ) + + showToast(errorText) // Reinvite with per-member shareHistory groupManager.reinviteMembers( @@ -232,20 +230,13 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onPromoteContact(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = false) - } - } - fun onRemoveContact(removeMessages: Boolean) { - _uiState.update { it -> - it.copy(ongoingAction =context.resources.getQuantityString( - R.plurals.removingMember, - selectedMembers.value.size, - selectedMembers.value.size - )) - } + val removeText = context.resources.getQuantityString( + R.plurals.removingMember, + selectedMembers.value.size, + selectedMembers.value.size + ) + showToast(removeText) performGroupOperation(showLoading = false) { val accountIdList = selectedMembers.value.map { it.accountId } @@ -260,10 +251,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onDismissError() { - _uiState.update { it.copy(error = null) } - } - /** * Perform a group operation, such as inviting a member, removing a member. * @@ -289,12 +276,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( try { task.await() } catch (e: Exception) { - _uiState.update { - it.copy( - error = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) - ) - } + val error = errorMessage?.invoke(e) + ?: context.getString(R.string.errorUnknown) + showToast(error) } finally { if (showLoading) { _uiState.update { it.copy(inProgress = false) } @@ -311,10 +295,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( footerCollapsed.update { !it } } - fun onDismissResend() { - _uiState.update { it.copy(ongoingAction = null) } - } - private fun toggleRemoveMembersDialog(visible : Boolean){ showRemoveMembersDialog.value = visible } @@ -333,10 +313,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( is Commands.ToggleFooter -> toggleFooter() - is Commands.DismissError -> onDismissError() - - is Commands.DismissResend -> onDismissResend() - is Commands.MemberClick -> onMemberItemClicked(command.member) is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) @@ -435,8 +411,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( val adminOptions : List = emptyList(), val inProgress: Boolean = false, - val error: String? = null, - val ongoingAction: String? = null, // search UI state: val searchQuery: String = "", @@ -474,10 +448,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( data object ShowRemoveMembersDialog : Commands data object DismissRemoveMembersDialog : Commands - data object DismissError : Commands - - data object DismissResend : Commands - data object ToggleFooter : Commands data object CloseFooter : Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index a2ca6d041f..148f31418f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -157,10 +157,6 @@ class PromoteMembersViewModel @AssistedInject constructor( } } - private fun showToast(text : String){ - Toast.makeText(context, text, Toast.LENGTH_SHORT).show() - } - private fun buildFooterState( selected: Set, isCollapsed: Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt index 42ce37ff07..17e79fd96c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -111,8 +111,6 @@ fun ManageMembers( ) { val searchFocused = uiState.isSearchFocused - val showingError = uiState.error - val showingOngoingAction = uiState.ongoingAction val handleBack: () -> Unit = { when { @@ -270,21 +268,6 @@ fun ManageMembers( if (uiState.inProgress) { LoadingDialog() } - - val context = LocalContext.current - - LaunchedEffect(showingError) { - if (showingError != null) { - Toast.makeText(context, showingError, Toast.LENGTH_SHORT).show() - sendCommand(DismissError) - } - } - LaunchedEffect(showingOngoingAction) { - if (showingOngoingAction != null) { - Toast.makeText(context, showingOngoingAction, Toast.LENGTH_SHORT).show() - sendCommand(DismissResend) - } - } } @Composable From 981220b29f80b079d143b835560e234b77454ebe Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 20 Nov 2025 16:53:55 +0800 Subject: [PATCH 16/23] Fixed toast thread --- .../groups/BaseGroupMembersViewModel.kt | 35 ++++++++++++ .../groups/ManageGroupAdminsViewModel.kt | 55 +++++------------- .../groups/ManageGroupMembersViewModel.kt | 56 ++++++------------- .../groups/PromoteMembersViewModel.kt | 37 +++--------- 4 files changed, 74 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index c7a859b5e2..649a6560e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.allWithStatus @@ -220,6 +223,38 @@ abstract class BaseGroupMembersViewModel( context, text, Toast.LENGTH_SHORT ).show() } + + /** + * Perform a group operation, such as inviting a member, removing a member. + * + * This is a helper function that encapsulates the common error handling and progress tracking. + */ + protected fun performGroupOperationCore( + showLoading: Boolean = false, + setLoading: (Boolean) -> Unit = {}, + errorMessage: ((Throwable) -> String?)? = null, + operation: suspend () -> Unit + ) { + viewModelScope.launch { + if (showLoading) setLoading(true) + + // We need to use GlobalScope here because we don't want + // any group operation to be cancelled when the view model is cleared. + @Suppress("OPT_IN_USAGE") + val task = GlobalScope.async { + operation() + } + + try { + task.await() + } catch (e: Throwable) { + val msg = errorMessage?.invoke(e) ?: context.getString(R.string.errorUnknown) + showToast(msg) + } finally { + if (showLoading) setLoading(false) + } + } + } } private fun stateOrder(status: GroupMember.Status?): Int = when (status) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index 36f3b9f64a..75065ff6b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -119,6 +119,10 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( } } + private fun setLoading(isLoading : Boolean){ + _uiState.update { it.copy(inProgress = isLoading) } + } + /** * Send promotions to all selected admins. */ @@ -126,18 +130,19 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( val selected = selectedAdmins.value if (selected.isEmpty()) return - performGroupOperation(showLoading = false) { - val accountIds = selected.map { it.accountId } + val accountIds = selected.map { it.accountId } - removeSearchState(clearSelection = true) + val resendingText = context.resources.getQuantityString( + R.plurals.resendingPromotion, + accountIds.size, + accountIds.size + ) - val resendingText = context.resources.getQuantityString( - R.plurals.resendingPromotion, - accountIds.size, - accountIds.size - ) + showToast(resendingText) - showToast(resendingText) + performGroupOperationCore(showLoading = false + , setLoading = ::setLoading) { + removeSearchState(clearSelection = true) groupManager.promoteMember( groupId, @@ -164,38 +169,6 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( footerCollapsed.update { !it } } - /** - * Shared helper for group operations (same pattern with ManageGroupMembersViewModel). - */ - private fun performGroupOperation( - showLoading: Boolean = true, - errorMessage: ((Throwable) -> String?)? = null, - operation: suspend () -> Unit - ) { - viewModelScope.launch { - if (showLoading) { - _uiState.update { it.copy(inProgress = true) } - } - - @Suppress("OPT_IN_USAGE") - val task = GlobalScope.async { - operation() - } - - try { - task.await() - } catch (e: Exception) { - val error = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) - showToast(error) - } finally { - if (showLoading) { - _uiState.update { it.copy(inProgress = false) } - } - } - } - } - private fun buildFooterState( selected: Set, isCollapsed: Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt index 8f9d257a41..4647202bd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -10,6 +10,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.getOrNull import org.session.libsession.database.StorageProtocol @@ -163,8 +165,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( showToast(sendInviteText) - performGroupOperation( + performGroupOperationCore( showLoading = false, + setLoading = ::setLoading, errorMessage = { err -> if (err is GroupInviteException) { err.format(context, recipientRepository).toString() @@ -184,8 +187,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( fun onResendInviteClicked() { if (selectedMembers.value.isEmpty()) return - performGroupOperation( + performGroupOperationCore( showLoading = false, + setLoading = ::setLoading, errorMessage = { err -> if (err is GroupInviteException) { err.format(context, recipientRepository).toString() @@ -211,7 +215,10 @@ class ManageGroupMembersViewModel @AssistedInject constructor( invites.size ) - showToast(errorText) + // is it better move the invites list outside the operation? + withContext(Dispatchers.Main) { + showToast(errorText) // now safely on main thread + } // Reinvite with per-member shareHistory groupManager.reinviteMembers( @@ -236,9 +243,10 @@ class ManageGroupMembersViewModel @AssistedInject constructor( selectedMembers.value.size, selectedMembers.value.size ) + showToast(removeText) - performGroupOperation(showLoading = false) { + performGroupOperationCore(showLoading = false, setLoading = ::setLoading) { val accountIdList = selectedMembers.value.map { it.accountId } removeSearchState(true) @@ -251,42 +259,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - /** - * Perform a group operation, such as inviting a member, removing a member. - * - * This is a helper function that encapsulates the common error handling and progress tracking. - */ - private fun performGroupOperation( - showLoading: Boolean = true, - errorMessage: ((Throwable) -> String?)? = null, - operation: suspend () -> Unit - ) { - viewModelScope.launch { - if (showLoading) { - _uiState.update { it.copy(inProgress = true) } - } - - // We need to use GlobalScope here because we don't want - // any group operation to be cancelled when the view model is cleared. - @Suppress("OPT_IN_USAGE") - val task = GlobalScope.async { - operation() - } - - try { - task.await() - } catch (e: Exception) { - val error = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) - showToast(error) - } finally { - if (showLoading) { - _uiState.update { it.copy(inProgress = false) } - } - } - } - } - fun clearSelection(){ _mutableSelectedMembers.value = emptySet() } @@ -299,6 +271,10 @@ class ManageGroupMembersViewModel @AssistedInject constructor( showRemoveMembersDialog.value = visible } + private fun setLoading(isLoading : Boolean){ + _uiState.update { it.copy(inProgress = true) } + } + fun onCommand(command: Commands) { when (command) { is Commands.ShowRemoveMembersDialog -> toggleRemoveMembersDialog(true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index 148f31418f..deff417e4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -117,18 +117,18 @@ class PromoteMembersViewModel @AssistedInject constructor( val selected = selectedMembers.value if (selected.isEmpty()) return - performGroupOperation() { - val accountIds = selected.map { it.accountId } + val accountIds = selected.map { it.accountId } - removeSearchState(clearSelection = true) + val promoteText = context.resources.getQuantityString( + R.plurals.resendingInvite, + selectedMembers.value.size, + selectedMembers.value.size + ) - val promoteText = context.resources.getQuantityString( - R.plurals.resendingInvite, - selectedMembers.value.size, - selectedMembers.value.size - ) + showToast(promoteText) - showToast(promoteText) + performGroupOperationCore { + removeSearchState(clearSelection = true) groupManager.promoteMember( groupId, @@ -138,25 +138,6 @@ class PromoteMembersViewModel @AssistedInject constructor( } } - private fun performGroupOperation( - errorMessage: ((Throwable) -> String?)? = null, - operation: suspend () -> Unit - ) { - viewModelScope.launch { - @Suppress("OPT_IN_USAGE") - val task = GlobalScope.async { - operation() - } - - try { - task.await() - } catch (e: Exception) { - showToast(errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown)) - } - } - } - private fun buildFooterState( selected: Set, isCollapsed: Boolean From 75a6979445d2eaeeabfcb1f2b061a2b5bdd37d46 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 21 Nov 2025 12:51:10 +0800 Subject: [PATCH 17/23] Throw error in promoting --- .../groups/BaseGroupMembersViewModel.kt | 3 ++ .../securesms/groups/GroupManagerV2Impl.kt | 28 +++++++++++++++---- .../groups/ManageGroupAdminsViewModel.kt | 12 ++++++-- .../groups/PromoteMembersViewModel.kt | 13 +++++++-- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 649a6560e7..c645c94fa9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils import java.util.EnumSet +import kotlin.coroutines.cancellation.CancellationException abstract class BaseGroupMembersViewModel( groupAddress: Address.Group, @@ -247,6 +248,8 @@ abstract class BaseGroupMembersViewModel( try { task.await() + } catch (e: CancellationException) { + return@launch } catch (e: Throwable) { val msg = errorMessage?.invoke(e) ?: context.getString(R.string.errorUnknown) showToast(msg) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index ed75a948ec..51e7fb8803 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -560,18 +560,17 @@ class GroupManagerV2Impl @Inject constructor( } } - // Wait and gather all the promote message sending result into a result map val promotedByMemberIDs = promotionDeferred - .mapValues { - runCatching { it.value.await() }.isSuccess + .mapValues { (_, deferred) -> + runCatching { deferred.await() } } // Update each member's status configFactory.withMutableGroupConfigs(group) { configs -> promotedByMemberIDs.asSequence() - .mapNotNull { (member, success) -> + .mapNotNull { (member, result) -> configs.groupMembers.get(member.hexString)?.apply { - if (success) { + if (result.isSuccess) { setPromotionSent() } else { setPromotionFailed() @@ -581,6 +580,25 @@ class GroupManagerV2Impl @Inject constructor( .forEach(configs.groupMembers::set) } + val failedMembers = promotedByMemberIDs + .filterValues { it.isFailure } + .keys + .toList() + + if (failedMembers.isNotEmpty()) { + val cause = promotedByMemberIDs.values + .firstOrNull { it.isFailure } + ?.exceptionOrNull() + ?: RuntimeException("Failed to promote ${failedMembers.size} member(s)") + + throw GroupInviteException( + isPromotion = true, + inviteeAccountIds = failedMembers.map { it.hexString }, + groupName = groupName ?: "", + isReinvite = isRepromote, + underlying = cause + ) + } if (!isRepromote) { messageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index 75065ff6b1..0f4e93c64d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupInviteException import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol @@ -140,8 +141,15 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( showToast(resendingText) - performGroupOperationCore(showLoading = false - , setLoading = ::setLoading) { + performGroupOperationCore( + showLoading = false, setLoading = ::setLoading, + errorMessage = { err -> + if (err is GroupInviteException) { + err.format(context, recipientRepository).toString() + } else { + null + } + }) { removeSearchState(clearSelection = true) groupManager.promoteMember( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index deff417e4e..f5b919b0bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupInviteException import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol @@ -127,8 +128,16 @@ class PromoteMembersViewModel @AssistedInject constructor( showToast(promoteText) - performGroupOperationCore { - removeSearchState(clearSelection = true) + performGroupOperationCore( + errorMessage = { err -> + if (err is GroupInviteException) { + err.format(context, recipientRepository).toString() + } else { + null + } + } + ) { + removeSearchState(clearSelection = true,) groupManager.promoteMember( groupId, From 801ccac83c169e867b78c137f94189be4a55a74d Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 21 Nov 2025 14:14:13 +0800 Subject: [PATCH 18/23] initial parent vm WIP --- .../settings/ConversationSettingsNavHost.kt | 11 ++++++ .../groups/BaseGroupMembersViewModel.kt | 2 - .../groups/ManageGroupAdminsViewModel.kt | 34 ++++++++++++----- .../groups/PromoteMembersViewModel.kt | 37 ------------------- .../groups/compose/PromoteMembersScreen.kt | 16 +++----- 5 files changed, 42 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index a4dbfa4ef7..633e64ddeb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -386,11 +386,22 @@ fun ConversationSettingsNavHost( factory.create(groupAddress = data.groupAddress) } + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry( + RouteManageAdmins(data.groupAddress) + ) + } + val manageGroupAdminsViewModel: ManageGroupAdminsViewModel = hiltViewModel(parentEntry) + PromoteMembersScreen( viewModel = viewModel, onBack = dropUnlessResumed { handleBack() }, + onPromoteClicked = { selectedMembers -> + manageGroupAdminsViewModel.onSendPromotionsClicked(selectedMembers) + handleBack() + } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index c645c94fa9..9b4ac2ae1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -248,8 +248,6 @@ abstract class BaseGroupMembersViewModel( try { task.await() - } catch (e: CancellationException) { - return@launch } catch (e: Throwable) { val msg = errorMessage?.invoke(e) ?: context.getString(R.string.errorUnknown) showToast(msg) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index 0f4e93c64d..8e6615a2b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -125,13 +125,27 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( } /** - * Send promotions to all selected admins. + * Send promotions to all selected admins (explicit selection from caller). */ - fun onSendPromotionsClicked(isResend : Boolean) { - val selected = selectedAdmins.value - if (selected.isEmpty()) return + fun onSendPromotionsClicked(selectedAdmins: Set) { + sendPromotions(members = selectedAdmins, isRepromote = false) + } - val accountIds = selected.map { it.accountId } + /** + * Resend promotions using locally selected admins. + * Used in the parent screen with admin list + */ + fun onResendPromotionsClicked() { + sendPromotions(isRepromote = true) + } + + private fun sendPromotions( + members: Set = selectedAdmins.value, + isRepromote: Boolean + ) { + if (members.isEmpty()) return + + val accountIds = members.map { it.accountId } val resendingText = context.resources.getQuantityString( R.plurals.resendingPromotion, @@ -142,20 +156,22 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( showToast(resendingText) performGroupOperationCore( - showLoading = false, setLoading = ::setLoading, + showLoading = false, + setLoading = ::setLoading, errorMessage = { err -> if (err is GroupInviteException) { err.format(context, recipientRepository).toString() } else { null } - }) { + } + ) { removeSearchState(clearSelection = true) groupManager.promoteMember( groupId, accountIds, - isRepromote = isResend + isRepromote = isRepromote ) } } @@ -203,7 +219,7 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( ), buttonLabel = GetString(context.getString(R.string.resend)), isDanger = false, - onClick = { onSendPromotionsClicked(true) } + onClick = { onResendPromotionsClicked() } ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index f5b919b0bd..2207eafe19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -114,39 +114,6 @@ class PromoteMembersViewModel @AssistedInject constructor( _mutableSelectedMembers.value = emptySet() } - fun sendPromotionInvites(){ - val selected = selectedMembers.value - if (selected.isEmpty()) return - - val accountIds = selected.map { it.accountId } - - val promoteText = context.resources.getQuantityString( - R.plurals.resendingInvite, - selectedMembers.value.size, - selectedMembers.value.size - ) - - showToast(promoteText) - - performGroupOperationCore( - errorMessage = { err -> - if (err is GroupInviteException) { - err.format(context, recipientRepository).toString() - } else { - null - } - } - ) { - removeSearchState(clearSelection = true,) - - groupManager.promoteMember( - groupId, - accountIds, - isRepromote = false - ) - } - } - private fun buildFooterState( selected: Set, isCollapsed: Boolean @@ -230,8 +197,6 @@ class PromoteMembersViewModel @AssistedInject constructor( _uiState.update { it.copy(showConfirmDialog = false) } } - is Commands.SendPromotionInvites -> sendPromotionInvites() - is Commands.ToggleFooter -> toggleFooter() is Commands.CloseFooter, @@ -254,8 +219,6 @@ class PromoteMembersViewModel @AssistedInject constructor( data object ShowConfirmDialog : Commands data object DismissConfirmDialog : Commands - data object SendPromotionInvites : Commands - data object ToggleFooter : Commands data object CloseFooter : Commands data object ClearSelection : Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt index 3fc9f97cd2..dd5335825c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.groups.compose -import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,11 +23,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -46,7 +43,6 @@ import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.Search import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowConfirmDialog import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowPromoteDialog import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ToggleFooter -import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SendPromotionInvites import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData @@ -64,6 +60,7 @@ import org.thoughtcrime.securesms.ui.theme.LocalType fun PromoteMembersScreen( viewModel: PromoteMembersViewModel, onBack: () -> Unit, + onPromoteClicked: (Set) -> Unit ) { val uiState = viewModel.uiState.collectAsState().value val searchQuery = viewModel.searchQuery.collectAsState().value @@ -78,7 +75,8 @@ fun PromoteMembersScreen( sendCommand = viewModel::onCommand, members = members, selectedMembers = selectedMembers, - hasActiveMembers = hasActiveMembers + hasActiveMembers = hasActiveMembers, + onPromoteClicked = onPromoteClicked ) } @@ -91,7 +89,8 @@ fun PromoteMembers( sendCommand: (command: Commands) -> Unit, members: List, selectedMembers: Set = emptySet(), - hasActiveMembers: Boolean = false + hasActiveMembers: Boolean = false, + onPromoteClicked: (Set) -> Unit ) { val searchFocused = uiState.isSearchFocused @@ -202,10 +201,7 @@ fun PromoteMembers( if (uiState.showConfirmDialog) { ConfirmDialog( sendCommand = sendCommand, - onConfirmClicked = { - sendCommand(SendPromotionInvites) - onBack() - }) + onConfirmClicked = { onPromoteClicked(selectedMembers) }) } if (uiState.showPromoteDialog) { From 03c2812e22a80061a8ff59b15102d48fcb20cd9e Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 24 Nov 2025 09:25:51 +0800 Subject: [PATCH 19/23] Auto navigate to promote members --- .../v2/settings/ConversationSettingsNavHost.kt | 18 ++++++++++-------- .../settings/ConversationSettingsViewModel.kt | 15 ++++++++++++--- .../groups/ManageGroupAdminsViewModel.kt | 17 +++++++++++------ .../groups/PromoteMembersViewModel.kt | 5 ----- .../thoughtcrime/securesms/ui/UINavigator.kt | 5 +++-- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 633e64ddeb..658e6b9014 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute @@ -78,9 +79,13 @@ sealed interface ConversationSettingsDestination: Parcelable { @Serializable @Parcelize data class RouteManageAdmins private constructor( - private val address: String - ): ConversationSettingsDestination { - constructor(groupAddress: Address.Group): this(groupAddress.address) + private val address: String, + val navigateToPromoteMembers: Boolean = false + ) : ConversationSettingsDestination { + constructor(groupAddress: Address.Group, navigateToPromoteMembers: Boolean = false) : this( + groupAddress.address, + navigateToPromoteMembers + ) val groupAddress: Address.Group get() = Address.Group(AccountId(address)) } @@ -243,7 +248,7 @@ fun ConversationSettingsNavHost( val viewModel = hiltViewModel { factory -> - factory.create(data.groupAddress, navigator) + factory.create(data.groupAddress, navigator, data.navigateToPromoteMembers) } ManageGroupAdminsScreen( @@ -254,7 +259,6 @@ fun ConversationSettingsNavHost( ) } - // Invite Contacts to group horizontalSlideComposable { backStackEntry -> val data: RouteInviteToGroup = backStackEntry.toRoute() @@ -387,9 +391,7 @@ fun ConversationSettingsNavHost( } val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry( - RouteManageAdmins(data.groupAddress) - ) + navController.previousBackStackEntry ?: error("") } val manageGroupAdminsViewModel: ManageGroupAdminsViewModel = hiltViewModel(parentEntry) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 96a610a57f..98cb337bea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -1054,9 +1054,18 @@ class ConversationSettingsViewModel @AssistedInject constructor( positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, onPositive = { - if (isUserLastAdmin) - navigateTo(ConversationSettingsDestination.RoutePromoteMembers(address)) - else leaveGroup() + if (isUserLastAdmin){ + // Calling this to have the ManageAdminScreen in the backstack so we can + // get its VM and PromoteMembersScreen can navigate back to it after sending promotions + navigateTo( + ConversationSettingsDestination.RouteManageAdmins( + groupAddress = address, + navigateToPromoteMembers = true + ) + ) + }else{ + leaveGroup() + } }, positiveStyleDanger = !isUserLastAdmin, onNegative = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index 8e6615a2b9..cd83345df3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.groups import android.content.Context -import android.widget.Toast import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.lifecycle.viewModelScope @@ -10,8 +9,6 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -44,6 +41,7 @@ import org.thoughtcrime.securesms.util.AvatarUtils class ManageGroupAdminsViewModel @AssistedInject constructor( @Assisted private val groupAddress: Address.Group, @Assisted private val navigator: UINavigator, + @Assisted private val openPromoteMembers: Boolean, @ApplicationContext private val context: Context, storage: StorageProtocol, private val configFactory: ConfigFactoryProtocol, @@ -98,6 +96,11 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( _uiState.update { it.copy(footer = footer) } } } + + if (openPromoteMembers) { + // Only runs once for this nav entry, so no loop on back + navigateToPromoteMembers() + } } fun onAdminItemClicked(member: GroupMemberState) { @@ -115,7 +118,8 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( private fun navigateToPromoteMembers() { viewModelScope.launch { navigator.navigate( - ConversationSettingsDestination.RoutePromoteMembers(groupAddress) + destination = ConversationSettingsDestination.RoutePromoteMembers(groupAddress), + debounce = false ) } } @@ -254,7 +258,7 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( val isSearchFocused: Boolean = false, //Collapsible footer - val footer: CollapsibleFooterState = CollapsibleFooterState() + val footer: CollapsibleFooterState = CollapsibleFooterState(), ) data class CollapsibleFooterState( @@ -289,7 +293,8 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( interface Factory { fun create( groupAddress: Address.Group, - navigator: UINavigator + navigator: UINavigator, + navigateToPromoteMembers: Boolean ): ManageGroupAdminsViewModel } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index 2207eafe19..6942f16d95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.groups import android.content.Context -import android.widget.Toast import androidx.lifecycle.viewModelScope import com.squareup.phrase.Phrase import dagger.assisted.Assisted @@ -9,8 +8,6 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -21,7 +18,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.groups.GroupInviteException import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol @@ -31,7 +27,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_K import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUtils -import kotlin.collections.map @HiltViewModel(assistedFactory = PromoteMembersViewModel.Factory::class) class PromoteMembersViewModel @AssistedInject constructor( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt index 8aa4c05bc3..b16d1ca55e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt @@ -18,10 +18,11 @@ class UINavigator () { suspend fun navigate( destination: T, - navOptions: NavOptionsBuilder.() -> Unit = {} + navOptions: NavOptionsBuilder.() -> Unit = {}, + debounce : Boolean = true // For when intentionally chaining navigations ) { val currentTime = System.currentTimeMillis() - if (currentTime - lastNavigationTime > navigationDebounceTime) { + if (!debounce || currentTime - lastNavigationTime > navigationDebounceTime) { lastNavigationTime = currentTime _navigationActions.send(NavigationAction.Navigate( destination = destination, From f11e0f12dc3557c10fae1b937227350870b11de0 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 24 Nov 2025 09:30:14 +0800 Subject: [PATCH 20/23] cleanup --- .../conversation/v2/settings/ConversationSettingsNavHost.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 658e6b9014..50891467e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -391,7 +391,7 @@ fun ConversationSettingsNavHost( } val parentEntry = remember(backStackEntry) { - navController.previousBackStackEntry ?: error("") + navController.previousBackStackEntry ?: error("RouteManageAdmin not in backstack") } val manageGroupAdminsViewModel: ManageGroupAdminsViewModel = hiltViewModel(parentEntry) From 14ac4005c4210c22c8dbf355d22fb5725a4a73b2 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 24 Nov 2025 10:44:46 +0800 Subject: [PATCH 21/23] Moved groupName into base vm --- .../securesms/groups/BaseGroupMembersViewModel.kt | 5 +++++ .../securesms/groups/ManageGroupAdminsViewModel.kt | 5 ----- .../securesms/groups/ManageGroupMembersViewModel.kt | 5 ----- .../thoughtcrime/securesms/groups/PromoteMembersViewModel.kt | 5 ----- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index 9b4ac2ae1e..b4cea4e4a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -83,6 +83,11 @@ abstract class BaseGroupMembersViewModel( } }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + // Current group name (for header / text, if needed) + val groupName: StateFlow = groupInfo + .map { it?.first?.name.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + private val mutableSearchQuery = MutableStateFlow("") val searchQuery: StateFlow get() = mutableSearchQuery diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt index cd83345df3..eb56e3f0df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -72,11 +72,6 @@ class ManageGroupAdminsViewModel @AssistedInject constructor( ) } - // Current group name (for header / text, if needed) - val groupName: StateFlow = groupInfo - .map { it?.first?.name.orEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") - private val _mutableSelectedAdmins = MutableStateFlow(emptySet()) val selectedAdmins: StateFlow> = _mutableSelectedAdmins diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt index 4647202bd8..377a98cb0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -55,11 +55,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ) : BaseGroupMembersViewModel(groupAddress, context, storage, configFactory, avatarUtils, recipientRepository) { private val groupId = groupAddress.accountId - // Output: The name of the group. This is the current name of the group, not the name being edited. - val groupName: StateFlow = groupInfo - .map { it?.first?.name.orEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") - // Output: whether we should show the "add members" button val showAddMembers: StateFlow = groupInfo .map { it?.first?.isUserAdmin == true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt index 6942f16d95..9cd0f408bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -47,11 +47,6 @@ class PromoteMembersViewModel @AssistedInject constructor( ) { private val groupId = groupAddress.accountId - // Current group name (for header / text, if needed) - val groupName: StateFlow = groupInfo - .map { it?.first?.name.orEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") - private val _mutableSelectedMembers = MutableStateFlow(emptySet()) val selectedMembers: StateFlow> = _mutableSelectedMembers From 601bc6228bd5c428cabf161e480c708f47cd73c5 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 24 Nov 2025 16:53:40 +0800 Subject: [PATCH 22/23] Has members flag fix --- .../thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index b4cea4e4a5..fe3e07b3e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -111,7 +111,7 @@ abstract class BaseGroupMembersViewModel( val hasActiveMembers: StateFlow = groupInfo - .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin} } + .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } .stateIn(viewModelScope, SharingStarted.Lazily, false) val hasNonAdminMembers: StateFlow = From 3c4e7348aff5a3f7663c30cdac8ef26ddd11152b Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 4 Dec 2025 08:27:14 +0800 Subject: [PATCH 23/23] Added error strings --- .../libsession/messaging/groups/GroupInviteException.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt index d2a2c5239f..7e5cd7a7d1 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt @@ -43,7 +43,7 @@ class GroupInviteException( if (second != null && third != null) { val errorString = - if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else + if (isPromotion) if (isReinvite) R.string.failedResendPromotionMultiple else R.string.adminPromotionFailedDescriptionMultiple else if (isReinvite) R.string.failedResendInviteMultiple else R.string.groupInviteFailedMultiple return Phrase.from(context, errorString) .put(NAME_KEY, first) @@ -51,7 +51,7 @@ class GroupInviteException( .put(GROUP_NAME_KEY, groupName) .format() } else if (second != null) { - val errorString = if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else + val errorString = if (isPromotion) if (isReinvite) R.string.failedResendPromotionTwo else R.string.adminPromotionFailedDescriptionTwo else if (isReinvite) R.string.failedResendInviteTwo else R.string.groupInviteFailedTwo return Phrase.from(context, errorString) .put(NAME_KEY, first) @@ -59,7 +59,7 @@ class GroupInviteException( .put(GROUP_NAME_KEY, groupName) .format() } else { - val errorString = if (isPromotion) R.string.adminPromotionFailedDescription else + val errorString = if (isPromotion) if (isReinvite) R.string.failedResendPromotion else R.string.adminPromotionFailedDescription else if (isReinvite) R.string.failedResendInvite else R.string.groupInviteFailedUser return Phrase.from(context, errorString) .put(NAME_KEY, first)