From f3b8de38e7114679b81d4866b8e382a4dd7044b8 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 29 Oct 2025 13:51:09 +0800 Subject: [PATCH 01/50] New message description icon change --- .../securesms/home/startconversation/newmessage/NewMessage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt index cf0cff108b..8981f6c99c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt @@ -124,7 +124,7 @@ private fun EnterAccountId( .fillMaxWidth(), style = LocalType.current.small, color = LocalColors.current.textSecondary, - iconRes = R.drawable.ic_circle_help, + iconRes = R.drawable.ic_square_arrow_up_right, onClick = onHelp ) } From ac4d7c2fa8be42768ad68c3dc0b58f44ae32bfd3 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 29 Oct 2025 15:33:53 +0800 Subject: [PATCH 02/50] Ons lookup failed error --- .../startconversation/newmessage/NewMessageViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 1402f7ac56..dcbcd0c864 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow @@ -19,6 +20,7 @@ import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.upsertContact import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation @@ -135,7 +137,9 @@ class NewMessageViewModel @Inject constructor( private fun Exception.toMessage() = when (this) { is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized) - else -> application.getString(R.string.onsErrorUnableToSearch) + else -> Phrase.from(application, R.string.errorNoLookupOns) + .put(APP_NAME_KEY, application.getString(R.string.app_name)) + .format().toString() } } From 65ec34e0d4a9befbc249f8b72f94b40e30d2a556 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 29 Oct 2025 15:36:56 +0800 Subject: [PATCH 03/50] unregistered ONS error string --- .../home/startconversation/newmessage/NewMessageViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index dcbcd0c864..4c94d380f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -136,7 +136,7 @@ class NewMessageViewModel @Inject constructor( } private fun Exception.toMessage() = when (this) { - is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized) + is SnodeAPI.Error.Generic -> application.getString(R.string.errorUnregisteredOns) else -> Phrase.from(application, R.string.errorNoLookupOns) .put(APP_NAME_KEY, application.getString(R.string.app_name)) .format().toString() From edee85b744cfda44ec75de11b1a113e3700bf52b Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 29 Oct 2025 18:01:34 +0800 Subject: [PATCH 04/50] Modal when clicking help --- .../StartConversationSheet.kt | 25 ++++++++++++++----- .../newmessage/NewMessageViewModel.kt | 20 +++++++++++++-- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index fc3652f386..9ce74b224b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -16,8 +16,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -39,6 +41,7 @@ import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.openUrl import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.components.BaseBottomSheet import org.thoughtcrime.securesms.ui.horizontalSlideComposable @@ -152,13 +155,17 @@ fun StartConversationNavHost( val viewModel = hiltViewModel() val uiState by viewModel.state.collectAsState(State()) + val helpUrl = "https://getsession.org/account-ids" + LaunchedEffect(Unit) { scope.launch { viewModel.success.collect { - context.startActivity(ConversationActivityV2.createIntent( - context, - address = it.address - )) + context.startActivity( + ConversationActivityV2.createIntent( + context, + address = it.address + ) + ) onClose() } @@ -169,10 +176,16 @@ fun StartConversationNavHost( uiState, viewModel.qrErrors, viewModel, - onBack = { scope.launch { navigator.navigateUp() }}, + onBack = { scope.launch { navigator.navigateUp() } }, onClose = onClose, - onHelp = { activity?.openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Account-ID-usernames-work") } + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ToggleUrlDialog) } ) + if (uiState.showUrlDialog) { + OpenURLAlertDialog( + url = helpUrl, + onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.ToggleUrlDialog) } + ) + } } // Create Group diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 4c94d380f0..6c16c7d94b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -24,6 +24,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.upsertContact import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.preferences.SettingsViewModel import org.thoughtcrime.securesms.ui.GetString import java.net.IDN import javax.inject.Inject @@ -124,7 +125,6 @@ class NewMessageViewModel @Inject constructor( if (address is Address.Standard) { viewModelScope.launch { _success.emit(Success(address)) } } - } private fun onUnvalidatedPublicKey(publicKey: String) { @@ -141,15 +141,31 @@ class NewMessageViewModel @Inject constructor( .put(APP_NAME_KEY, application.getString(R.string.app_name)) .format().toString() } + + fun onCommand(commands: Commands) { + when (commands) { + is Commands.ToggleUrlDialog -> { + _state.update { it.copy(showUrlDialog = !it.showUrlDialog) } + } + } + } + + sealed interface Commands { + data object ToggleUrlDialog : Commands + } } data class State( val newMessageIdOrOns: String = "", val isTextErrorColor: Boolean = false, val error: GetString? = null, - val loading: Boolean = false + val loading: Boolean = false, + val showUrlDialog : Boolean = false ) { val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() } + + + data class Success(val address: Address.Standard) \ No newline at end of file From d3b72247c00270cb590bd9d39c23d4dfbe386e16 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 30 Oct 2025 10:30:01 +0800 Subject: [PATCH 05/50] Invalid account id check --- .../session/libsignal/utilities/Validation.kt | 20 +++++++++++++++++-- .../newmessage/NewMessageViewModel.kt | 16 ++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/session/libsignal/utilities/Validation.kt b/app/src/main/java/org/session/libsignal/utilities/Validation.kt index fdf9bd386f..eaa1fa1bab 100644 --- a/app/src/main/java/org/session/libsignal/utilities/Validation.kt +++ b/app/src/main/java/org/session/libsignal/utilities/Validation.kt @@ -4,8 +4,24 @@ object PublicKeyValidation { private val HEX_CHARACTERS = "0123456789ABCDEFabcdef".toSet() private val INVALID_PREFIXES = setOf(IdPrefix.GROUP, IdPrefix.BLINDED, IdPrefix.BLINDEDV2) - fun isValid(candidate: String, isPrefixRequired: Boolean = true): Boolean = hasValidLength(candidate) && isValidHexEncoding(candidate) && (!isPrefixRequired || IdPrefix.fromValue(candidate) != null) + fun isValid(candidate: String, isPrefixRequired: Boolean = true): Boolean { + if (!hasValidLength(candidate)) return false + + val prefix = IdPrefix.fromValue(candidate) + + // Handle invalid Account ID conditions + // Case 1: Standard prefix "05" but not valid hex + if (prefix == IdPrefix.STANDARD && !isValidHexEncoding(candidate)) return false + + // Case 2: Blinded or Group IDs should never be accepted as valid Account IDs + if (prefix in INVALID_PREFIXES) return false + + // Standard validity rules + return isValidHexEncoding(candidate) && + (!isPrefixRequired || prefix != null) + } + fun hasValidPrefix(candidate: String) = IdPrefix.fromValue(candidate) !in INVALID_PREFIXES - private fun hasValidLength(candidate: String) = candidate.length == 66 + fun hasValidLength(candidate: String) = candidate.length == 66 private fun isValidHexEncoding(candidate: String) = HEX_CHARACTERS.containsAll(candidate.toSet()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 6c16c7d94b..0c3db65d6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -72,10 +72,20 @@ class NewMessageViewModel @Inject constructor( } } - if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { - onUnvalidatedPublicKey(publicKey = idOrONS) + if (PublicKeyValidation.hasValidLength(idOrONS)) { + if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { + onUnvalidatedPublicKey(idOrONS) + } else { + _state.update { + it.copy( + isTextErrorColor = true, + error = GetString(R.string.accountIdErrorInvalid), + loading = false + ) + } + } } else { - resolveONS(ons = idOrONS) + resolveONS(idOrONS) } } From 49d20bb7053be892c0593fb87a50434b9ae32bb9 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 30 Oct 2025 14:02:54 +0800 Subject: [PATCH 06/50] Updated url dialog commands --- .../startconversation/StartConversationSheet.kt | 4 ++-- .../newmessage/NewMessageViewModel.kt | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index 9ce74b224b..9ce71dfba7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -178,12 +178,12 @@ fun StartConversationNavHost( viewModel, onBack = { scope.launch { navigator.navigateUp() } }, onClose = onClose, - onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ToggleUrlDialog) } + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } ) if (uiState.showUrlDialog) { OpenURLAlertDialog( url = helpUrl, - onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.ToggleUrlDialog) } + onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 0c3db65d6d..d76cdcf457 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -154,14 +154,23 @@ class NewMessageViewModel @Inject constructor( fun onCommand(commands: Commands) { when (commands) { - is Commands.ToggleUrlDialog -> { - _state.update { it.copy(showUrlDialog = !it.showUrlDialog) } + is Commands.ShowUrlDialog -> { + _state.update { it.copy(showUrlDialog = true) } + } + + is Commands.DismissUrlDialog -> { + _state.update { + it.copy( + showUrlDialog = false + ) + } } } } sealed interface Commands { - data object ToggleUrlDialog : Commands + data object ShowUrlDialog : Commands + data object DismissUrlDialog : Commands } } From 97ed8791dbb84313206258f3f97b34fecde1e60f Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 30 Oct 2025 16:10:08 +0800 Subject: [PATCH 07/50] manage admin menu --- .../v2/settings/ConversationSettingsViewModel.kt | 14 ++++++++++++++ .../main/res/drawable/ic_add_admin_custom.xml | 16 ++++++++++++++++ .../src/main/res/values/strings.xml | 1 + 3 files changed, 31 insertions(+) create mode 100644 app/src/main/res/drawable/ic_add_admin_custom.xml 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 0d98c5c126..841b7d696f 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 @@ -281,6 +281,19 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } + private val optionManageAdmins: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.manageAdmins), + icon = R.drawable.ic_add_admin_custom, + qaTag = R.string.qa_conversation_settings_manage_members, + onClick = { + (address as? Address.Group)?.let { + navigateTo(ConversationSettingsDestination.RouteManageMembers(it)) + } + } + ) + } + private val optionLeaveGroup: OptionsItem by lazy{ OptionsItem( name = context.getString(R.string.groupLeave), @@ -576,6 +589,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( adminOptions.addAll( listOf( optionManageMembers, + optionManageAdmins, optionDisappearingMessage(disappearingSubtitle) ) ) diff --git a/app/src/main/res/drawable/ic_add_admin_custom.xml b/app/src/main/res/drawable/ic_add_admin_custom.xml new file mode 100644 index 0000000000..2c885d7aec --- /dev/null +++ b/app/src/main/res/drawable/ic_add_admin_custom.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index a727f999e5..e86504c76a 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -126,6 +126,7 @@ hide-nts-menu-option copy-community-url-menu-option leave-community-menu-option + manage-admins-menu-option manage-members-menu-option group-members-menu-option invite-contacts-menu-option From 32d2d7fd983ea96903ec5a85fe4dfaf6c91424c4 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 30 Oct 2025 16:38:36 +0800 Subject: [PATCH 08/50] Removed groupv2 banners --- .../conversation/v2/settings/ConversationSettingsNavHost.kt | 4 +--- .../thoughtcrime/securesms/groups/compose/EditGroupScreen.kt | 2 -- .../home/startconversation/group/CreateGroupScreen.kt | 2 -- 3 files changed, 1 insertion(+), 7 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 5a540d1d0c..f022bb6b1c 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 @@ -232,9 +232,7 @@ fun ConversationSettingsNavHost( onBack = dropUnlessResumed { handleBack() }, - banner = { - GroupMinimumVersionBanner() - } + banner = {} ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 2a5b7071b4..3b1f999811 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -130,8 +130,6 @@ fun EditGroup( contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddingValues -> Column(modifier = Modifier.padding(paddingValues).consumeWindowInsets(paddingValues)) { - GroupMinimumVersionBanner() - // Group name title Text( text = groupName, diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt index b84c0de572..0ba376ef9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt @@ -124,8 +124,6 @@ fun CreateGroup( modifier = modifier.padding(paddings).consumeWindowInsets(paddings), horizontalAlignment = Alignment.CenterHorizontally, ) { - GroupMinimumVersionBanner() - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) SessionOutlinedTextField( From 91830a2bea75eb0024013e79faff3ad72e5a1252 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 30 Oct 2025 16:44:52 +0800 Subject: [PATCH 09/50] Leave group option --- .../conversation/v2/settings/ConversationSettingsViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 841b7d696f..cf5c6ad7cc 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 @@ -581,6 +581,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( dangerOptions.addAll( listOf( optionClearMessages, + optionLeaveGroup, optionDeleteGroup ) ) From 417642a6c1cf5b57a80a5724c56cd11ba2fd21b8 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 3 Nov 2025 10:40:00 +0800 Subject: [PATCH 10/50] options --- .../securesms/groups/EditGroupViewModel.kt | 9 ++ .../groups/compose/EditGroupScreen.kt | 124 +++++++++++++----- .../res/drawable/ic_user_round_search.xml | 11 ++ 3 files changed, 112 insertions(+), 32 deletions(-) create mode 100644 app/src/main/res/drawable/ic_user_round_search.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index bacdf3ae37..fbe5980f8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -1,6 +1,8 @@ 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 @@ -183,6 +185,13 @@ class EditGroupViewModel @AssistedInject constructor( _clickedMember.value = null } + data class OptionsItem( + val name: String, + @DrawableRes val icon: Int, + @StringRes val qaTag: Int? = null, + val onClick: () -> Unit + ) + @AssistedFactory interface Factory { fun create(groupAddress: Address.Group): EditGroupViewModel diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 3b1f999811..28663f6d6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.groups.compose +import android.R.attr.data import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -31,7 +32,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Alignment.Companion.CenterVertically 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.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -50,14 +53,19 @@ import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.EditGroupViewModel import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonData +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.components.ActionSheet import org.thoughtcrime.securesms.ui.components.ActionSheetItemData import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.AccentOutlineButton 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 @@ -65,7 +73,9 @@ 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 +import org.thoughtcrime.securesms.ui.theme.dangerButtonColors import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.ui.theme.transparentButtonColors import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement @@ -120,6 +130,19 @@ fun EditGroup( val maxNameWidth = 240.dp + val optionsList: List = listOf( + EditGroupViewModel.OptionsItem( + name = LocalResources.current.getString(R.string.membersInvite), + icon = R.drawable.ic_user_round_plus, + onClick = { onAddMemberClick() } + ), + EditGroupViewModel.OptionsItem( + name = LocalResources.current.getString(R.string.accountIdOrOnsInvite), + icon = R.drawable.ic_user_round_search, + onClick = { onAddMemberClick() } + ) + ) + Scaffold( topBar = { BackAppBar( @@ -129,45 +152,82 @@ fun EditGroup( }, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddingValues -> - Column(modifier = Modifier.padding(paddingValues).consumeWindowInsets(paddingValues)) { - // Group name title - Text( - text = groupName, - style = LocalType.current.h4, - textAlign = TextAlign.Center, - modifier = Modifier - .align(CenterHorizontally) - .widthIn(max = maxNameWidth) - .padding(vertical = LocalDimensions.current.smallSpacing), - ) + Column(modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues)) { - // Header & Add member button - Row( - modifier = Modifier.padding( - horizontal = LocalDimensions.current.smallSpacing, - vertical = LocalDimensions.current.xxsSpacing - ), - verticalAlignment = CenterVertically + Cell( + modifier = Modifier.fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing), ) { - Text( - stringResource(R.string.groupMembers), - modifier = Modifier.weight(1f), - style = LocalType.current.large, - color = LocalColors.current.text - ) - - if (showAddMembers) { - AccentOutlineButton( - stringResource(R.string.membersInvite), - onClick = onAddMemberClick, - modifier = Modifier.qaTag(R.string.AccessibilityId_membersInvite) - ) + Column { + optionsList.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + optionsList.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick, + ) + + if(index != optionsList.lastIndex) Divider() + } } } + // Group name title +// Text( +// text = groupName, +// style = LocalType.current.h4, +// textAlign = TextAlign.Center, +// modifier = Modifier +// .align(CenterHorizontally) +// .widthIn(max = maxNameWidth) +// .padding(vertical = LocalDimensions.current.smallSpacing), +// ) + + // Header & Add member button +// Row( +// modifier = Modifier.padding( +// horizontal = LocalDimensions.current.smallSpacing, +// vertical = LocalDimensions.current.xxsSpacing +// ), +// verticalAlignment = CenterVertically +// ) { +// Text( +// stringResource(R.string.groupMembers), +// modifier = Modifier.weight(1f), +// style = LocalType.current.large, +// color = LocalColors.current.text +// ) +// +// if (showAddMembers) { +// AccentOutlineButton( +// stringResource(R.string.membersInvite), +// onClick = onAddMemberClick, +// modifier = Modifier.qaTag(R.string.AccessibilityId_membersInvite) +// ) +// } +// } + + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.smallSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = LocalResources.current.getString(R.string.membersNonAdmins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) // List of members - LazyColumn(modifier = Modifier.weight(1f).imePadding()) { + LazyColumn(modifier = Modifier + .weight(1f) + .imePadding()) { items(members) { member -> // Each member's view EditMemberItem( diff --git a/app/src/main/res/drawable/ic_user_round_search.xml b/app/src/main/res/drawable/ic_user_round_search.xml new file mode 100644 index 0000000000..51fb13d6b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_search.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + From 06458f3c1f5c72019724fb81d8236178589f2658 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 3 Nov 2025 12:19:35 +0800 Subject: [PATCH 11/50] Initial search with cancel --- .../groups/compose/EditGroupScreen.kt | 16 ++- .../thoughtcrime/securesms/ui/Components.kt | 114 +++++++++++++++++- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 28663f6d6b..f692ce626e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -11,6 +11,7 @@ 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.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only @@ -59,6 +60,7 @@ 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.SearchBarWithCancel import org.thoughtcrime.securesms.ui.components.ActionSheet import org.thoughtcrime.securesms.ui.components.ActionSheetItemData import org.thoughtcrime.securesms.ui.components.BackAppBar @@ -216,7 +218,7 @@ fun EditGroup( Text( modifier = Modifier.padding( - start = LocalDimensions.current.smallSpacing, + start = LocalDimensions.current.mediumSpacing, bottom = LocalDimensions.current.smallSpacing ), text = LocalResources.current.getString(R.string.membersNonAdmins), @@ -224,6 +226,18 @@ fun EditGroup( color = LocalColors.current.textSecondary ) + SearchBarWithCancel( + query = "", + onValueChanged = { }, + onClear = { }, + placeholder = "Search", + enabled = true, + isFocused = false, + modifier = Modifier.padding(horizontal =LocalDimensions.current.smallSpacing) + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + // List of members LazyColumn(modifier = Modifier .weight(1f) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 2ad98b9ace..66baa933ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -99,6 +99,8 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -761,6 +763,96 @@ fun SearchBar( ) } +/** + * Search with the cancel action + */ + +@Composable +fun SearchBarWithCancel( + query: String, + onValueChanged: (String) -> Unit, + onClear: () -> Unit, + isFocused: Boolean, + modifier: Modifier = Modifier, + placeholder: String? = null, + enabled: Boolean = true, + backgroundColor: Color = LocalColors.current.backgroundSecondary, +) { + val focusManager = LocalFocusManager.current + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth() + ) { + // Search field + BasicTextField( + singleLine = true, + value = query, + onValueChange = onValueChanged, + enabled = enabled, + textStyle = LocalType.current.base.copy(color = LocalColors.current.text), + cursorBrush = SolidColor(LocalColors.current.text), + modifier = Modifier + .weight(1f) // leave room on the right for the cancel button + .heightIn(min = LocalDimensions.current.minSearchInputHeight) + .background(backgroundColor, MaterialTheme.shapes.small), + decorationBox = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.textSecondary), + modifier = Modifier + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ) + .size(LocalDimensions.current.iconSmall) + ) + + Box( + modifier = Modifier + .weight(1f) + .padding(end = LocalDimensions.current.smallSpacing) + ) { + innerTextField() + if (query.isEmpty() && placeholder != null) { + Text( + modifier = Modifier.qaTag(R.string.qa_conversation_search_input), + text = placeholder, + color = LocalColors.current.textSecondary, + style = LocalType.current.xl + ) + } + } + } + } + ) + + // Right-side Cancel (outside the search field) + AnimatedVisibility(visible = isFocused) { + Text( + text = LocalResources.current.getString(R.string.cancel), + style = LocalType.current.base, + color = LocalColors.current.text, + modifier = Modifier + .clickable { + onClear() + focusManager.clearFocus(force = true) + } + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ) + ) + } + } +} + /** * CollapsibleFooterAction */ @@ -938,7 +1030,9 @@ private fun CollapsibleFooterActions( modifier = Modifier .padding(start = LocalDimensions.current.smallSpacing) .then( - if (single) Modifier.wrapContentWidth().widthIn(max = capDp) + if (single) Modifier + .wrapContentWidth() + .widthIn(max = capDp) else Modifier.width(equalWidthDp) ) ) { @@ -1483,4 +1577,22 @@ fun PreviewActionRowItems() { ) } } +} + + +@Preview +@Composable +fun PreviewSearchWithCancel( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + SearchBarWithCancel( + query = "Test Query", + onValueChanged = { }, + onClear = { }, + placeholder = "Search", + enabled = true, + isFocused = true + ) + } } \ No newline at end of file From 6d81de60a293a28c9453f67a9dddfa2da9f97f2b Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 3 Nov 2025 12:55:24 +0800 Subject: [PATCH 12/50] fix search state --- .../securesms/groups/EditGroupViewModel.kt | 8 ++ .../groups/compose/EditGroupScreen.kt | 73 +++++++++++++------ .../thoughtcrime/securesms/ui/Components.kt | 8 +- 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index fbe5980f8f..31e24f65ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -67,6 +67,14 @@ class EditGroupViewModel @AssistedInject constructor( val excludingAccountIDsFromContactSelection: Set get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId.hexString }.orEmpty() +// Output: Intermediate states + private val mutableSearchFocused = MutableStateFlow(false) + val searchFocused: StateFlow get() = mutableSearchFocused + + fun onSearchFocusChanged(isFocused :Boolean){ + mutableSearchFocused.value = isFocused + } + fun onContactSelected(contacts: Set
) { performGroupOperation( showLoading = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index f692ce626e..5982f7d852 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.groups.compose import android.R.attr.data +import android.R.attr.name import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -103,6 +104,11 @@ fun EditGroupScreen( hideActionSheet = viewModel::hideActionBottomSheet, clickedMember = viewModel.clickedMember.collectAsState().value, showLoading = viewModel.inProgress.collectAsState().value, + searchQuery= viewModel.searchQuery.collectAsState().value, + searchFocused = viewModel.searchFocused.collectAsState().value, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSearchFocusChanged = viewModel::onSearchFocusChanged, + onSearchQueryClear = {viewModel.onSearchQueryChanged("") } ) } @@ -117,6 +123,11 @@ fun EditGroup( onPromoteClick: (accountId: AccountId) -> Unit, onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit, onMemberClicked: (GroupMemberState) -> Unit, + onSearchFocusChanged : (isFocused : Boolean) -> Unit, + searchFocused : Boolean, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchQueryClear: () -> Unit, hideActionSheet: () -> Unit, clickedMember: GroupMemberState?, groupName: String, @@ -158,25 +169,28 @@ fun EditGroup( .padding(paddingValues) .consumeWindowInsets(paddingValues)) { - Cell( - modifier = Modifier.fillMaxWidth() - .padding(LocalDimensions.current.smallSpacing), - ) { - Column { - optionsList.forEachIndexed { index, option -> - ItemButton( - modifier = Modifier.qaTag(option.qaTag), - text = annotatedStringResource(option.name), - iconRes = option.icon, - shape = when (index) { - 0 -> getCellTopShape() - optionsList.lastIndex -> getCellBottomShape() - else -> RectangleShape - }, - onClick = option.onClick, - ) - - if(index != optionsList.lastIndex) Divider() + if(showAddMembers){ + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing), + ) { + Column { + optionsList.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + optionsList.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick, + ) + + if(index != optionsList.lastIndex) Divider() + } } } } @@ -227,13 +241,14 @@ fun EditGroup( ) SearchBarWithCancel( - query = "", - onValueChanged = { }, - onClear = { }, + query = searchQuery, + onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, placeholder = "Search", enabled = true, - isFocused = false, - modifier = Modifier.padding(horizontal =LocalDimensions.current.smallSpacing) + isFocused = searchFocused, + modifier = Modifier.padding(horizontal =LocalDimensions.current.smallSpacing), + onFocusChanged = onSearchFocusChanged ) Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) @@ -532,6 +547,11 @@ private fun EditGroupPreviewSheet() { hideActionSheet = {}, clickedMember = oneMember, showLoading = false, + searchQuery = "Test", + onSearchQueryChanged = { }, + onSearchFocusChanged = { }, + searchFocused = false, + onSearchQueryClear = {}, ) } } @@ -627,6 +647,11 @@ private fun EditGroupEditNamePreview( hideActionSheet = {}, clickedMember = null, showLoading = false, + searchQuery = "", + onSearchQueryChanged = { }, + onSearchFocusChanged = {}, + searchFocused = true, + onSearchQueryClear = {}, ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 66baa933ae..8784a5bdbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -82,6 +82,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode @@ -773,6 +774,7 @@ fun SearchBarWithCancel( onValueChanged: (String) -> Unit, onClear: () -> Unit, isFocused: Boolean, + onFocusChanged: (Boolean) -> Unit, modifier: Modifier = Modifier, placeholder: String? = null, enabled: Boolean = true, @@ -795,7 +797,8 @@ fun SearchBarWithCancel( modifier = Modifier .weight(1f) // leave room on the right for the cancel button .heightIn(min = LocalDimensions.current.minSearchInputHeight) - .background(backgroundColor, MaterialTheme.shapes.small), + .background(backgroundColor, MaterialTheme.shapes.small) + .onFocusChanged { onFocusChanged(it.isFocused) }, decorationBox = { innerTextField -> Row( verticalAlignment = Alignment.CenterVertically, @@ -1592,7 +1595,8 @@ fun PreviewSearchWithCancel( onClear = { }, placeholder = "Search", enabled = true, - isFocused = true + isFocused = true, + onFocusChanged = {} ) } } \ No newline at end of file From d93ad0287873de23ab6368e0e65190dc8aa4da8b Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 3 Nov 2025 13:11:33 +0800 Subject: [PATCH 13/50] Animation for focused state: --- .../groups/compose/EditGroupScreen.kt | 81 ++++++++----------- 1 file changed, 33 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 5982f7d852..88aaf15c7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -3,6 +3,12 @@ package org.thoughtcrime.securesms.groups.compose import android.R.attr.data import android.R.attr.name import android.widget.Toast +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.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier @@ -141,8 +148,6 @@ fun EditGroup( mutableStateOf(null) } - val maxNameWidth = 240.dp - val optionsList: List = listOf( EditGroupViewModel.OptionsItem( name = LocalResources.current.getString(R.string.membersInvite), @@ -169,7 +174,20 @@ fun EditGroup( .padding(paddingValues) .consumeWindowInsets(paddingValues)) { - if(showAddMembers){ + AnimatedVisibility( + // show only when add-members is enabled AND search is not focused + visible = showAddMembers && !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() @@ -189,56 +207,23 @@ fun EditGroup( onClick = option.onClick, ) - if(index != optionsList.lastIndex) Divider() + if (index != optionsList.lastIndex) Divider() } } } } - // Group name title -// Text( -// text = groupName, -// style = LocalType.current.h4, -// textAlign = TextAlign.Center, -// modifier = Modifier -// .align(CenterHorizontally) -// .widthIn(max = maxNameWidth) -// .padding(vertical = LocalDimensions.current.smallSpacing), -// ) - - // Header & Add member button -// Row( -// modifier = Modifier.padding( -// horizontal = LocalDimensions.current.smallSpacing, -// vertical = LocalDimensions.current.xxsSpacing -// ), -// verticalAlignment = CenterVertically -// ) { -// Text( -// stringResource(R.string.groupMembers), -// modifier = Modifier.weight(1f), -// style = LocalType.current.large, -// color = LocalColors.current.text -// ) -// -// if (showAddMembers) { -// AccentOutlineButton( -// stringResource(R.string.membersInvite), -// onClick = onAddMemberClick, -// modifier = Modifier.qaTag(R.string.AccessibilityId_membersInvite) -// ) -// } -// } - - Text( - modifier = Modifier.padding( - start = LocalDimensions.current.mediumSpacing, - bottom = LocalDimensions.current.smallSpacing - ), - text = LocalResources.current.getString(R.string.membersNonAdmins), - style = LocalType.current.base, - color = LocalColors.current.textSecondary - ) + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = LocalResources.current.getString(R.string.membersNonAdmins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } SearchBarWithCancel( query = searchQuery, From 2927aa2fbec8e22f027a847e2f484e082d022330 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 3 Nov 2025 14:03:27 +0800 Subject: [PATCH 14/50] cleanup, Added non-admin members list --- .../securesms/groups/BaseGroupMembersViewModel.kt | 10 ++++++++++ .../securesms/groups/compose/EditGroupScreen.kt | 9 +-------- .../java/org/thoughtcrime/securesms/ui/Components.kt | 5 +++-- 3 files changed, 14 insertions(+), 10 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 28671f9f19..475a57755f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -89,6 +89,16 @@ abstract class BaseGroupMembersViewModel( ::filterContacts ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + // Output: List of only NON-ADMINS + @OptIn(FlowPreview::class) + val nonAdminMembers: StateFlow> = combine( + groupInfo.map { it?.second.orEmpty() }, + mutableSearchQuery.debounce(100L), + ::filterContacts + ) + .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/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 88aaf15c7d..203e34859d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.groups.compose -import android.R.attr.data -import android.R.attr.name import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -24,7 +22,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -47,7 +44,6 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -72,7 +68,6 @@ import org.thoughtcrime.securesms.ui.SearchBarWithCancel import org.thoughtcrime.securesms.ui.components.ActionSheet import org.thoughtcrime.securesms.ui.components.ActionSheetItemData import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.getCellBottomShape import org.thoughtcrime.securesms.ui.getCellTopShape @@ -83,9 +78,7 @@ 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 -import org.thoughtcrime.securesms.ui.theme.dangerButtonColors import org.thoughtcrime.securesms.ui.theme.primaryBlue -import org.thoughtcrime.securesms.ui.theme.transparentButtonColors import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement @@ -101,7 +94,7 @@ fun EditGroupScreen( onResendInviteClick = viewModel::onResendInviteClicked, onPromoteClick = viewModel::onPromoteContact, onRemoveClick = viewModel::onRemoveContact, - members = viewModel.members.collectAsState().value, + members = viewModel.nonAdminMembers.collectAsState().value, groupName = viewModel.groupName.collectAsState().value, showAddMembers = viewModel.showAddMembers.collectAsState().value, onResendPromotionClick = viewModel::onResendPromotionClicked, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 8784a5bdbf..0f27c79fc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -784,7 +784,9 @@ fun SearchBarWithCancel( Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + ) { // Search field BasicTextField( @@ -848,7 +850,6 @@ fun SearchBarWithCancel( focusManager.clearFocus(force = true) } .padding( - horizontal = LocalDimensions.current.smallSpacing, vertical = LocalDimensions.current.xxsSpacing ) ) From a6edb1a67d32c1780e65b95564f8eefe7798e944 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 3 Nov 2025 15:41:49 +0800 Subject: [PATCH 15/50] Selected items --- .../groups/BaseGroupMembersViewModel.kt | 4 +-- .../securesms/groups/EditGroupViewModel.kt | 11 ++++++ .../groups/compose/EditGroupScreen.kt | 36 +++++++++---------- 3 files changed, 30 insertions(+), 21 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 475a57755f..286b673121 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -150,7 +150,7 @@ abstract class BaseGroupMembersViewModel( showProBadge = proStatus.shouldShowProBadge(), avatarUIData = avatarUtils.getUIDataFromAccountId(memberAccountId.hexString), clickable = !isMyself, - statusLabel = getMemberLabel(status, context, amIAdmin), + statusLabel = getMemberLabel(status, context, amIAdmin) ) } @@ -205,7 +205,7 @@ data class GroupMemberState( val canRemove: Boolean, val canPromote: Boolean, val clickable: Boolean, - val statusLabel: String, + val statusLabel: String ) { val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index 31e24f65ba..b2fe42e1a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -16,6 +16,7 @@ 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 network.loki.messenger.libsession_util.getOrNull @@ -71,6 +72,16 @@ class EditGroupViewModel @AssistedInject constructor( private val mutableSearchFocused = MutableStateFlow(false) val searchFocused: StateFlow get() = mutableSearchFocused + private val _mutableSelectedMemberAccountIds = MutableStateFlow(emptySet()) + val selectedMemberAccountIds: StateFlow> = _mutableSelectedMemberAccountIds + + fun onMemberItemClicked(accountId: AccountId) { + val newSet = _mutableSelectedMemberAccountIds.value.toHashSet() + if (!newSet.remove(accountId)) { + newSet.add(accountId) + } + _mutableSelectedMemberAccountIds.value = newSet + } fun onSearchFocusChanged(isFocused :Boolean){ mutableSearchFocused.value = isFocused } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 203e34859d..096840309e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -95,12 +95,13 @@ fun EditGroupScreen( onPromoteClick = viewModel::onPromoteContact, onRemoveClick = viewModel::onRemoveContact, members = viewModel.nonAdminMembers.collectAsState().value, + selectedAccountIds = viewModel.selectedMemberAccountIds.collectAsState().value, groupName = viewModel.groupName.collectAsState().value, showAddMembers = viewModel.showAddMembers.collectAsState().value, onResendPromotionClick = viewModel::onResendPromotionClicked, showingError = viewModel.error.collectAsState().value, onErrorDismissed = viewModel::onDismissError, - onMemberClicked = viewModel::onMemberClicked, + onMemberClicked = viewModel::onMemberItemClicked, hideActionSheet = viewModel::hideActionBottomSheet, clickedMember = viewModel.clickedMember.collectAsState().value, showLoading = viewModel.inProgress.collectAsState().value, @@ -122,7 +123,7 @@ fun EditGroup( onResendPromotionClick: (accountId: AccountId) -> Unit, onPromoteClick: (accountId: AccountId) -> Unit, onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit, - onMemberClicked: (GroupMemberState) -> Unit, + onMemberClicked: (accountId: AccountId) -> Unit, onSearchFocusChanged : (isFocused : Boolean) -> Unit, searchFocused : Boolean, searchQuery: String, @@ -132,6 +133,7 @@ fun EditGroup( clickedMember: GroupMemberState?, groupName: String, members: List, + selectedAccountIds: Set = emptySet(), showAddMembers: Boolean, showingError: String?, showLoading: Boolean, @@ -240,7 +242,8 @@ fun EditGroup( EditMemberItem( modifier = Modifier.fillMaxWidth(), member = member, - onClick = { onMemberClicked(member) } + onClick = { onMemberClicked(member.accountId) }, + selected = member.accountId in selectedAccountIds ) } @@ -409,9 +412,10 @@ private fun MemberActionSheet( fun EditMemberItem( member: GroupMemberState, onClick: (address: Address) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + selected: Boolean = false ) { - MemberItem( + RadioMemberItem( address = Address.fromSerialized(member.accountId.hexString), title = member.name, subtitle = member.statusLabel, @@ -423,17 +427,11 @@ fun EditMemberItem( showAsAdmin = member.showAsAdmin, showProBadge = member.showProBadge, avatarUIData = member.avatarUIData, - onClick = if(member.clickable) onClick else null, - modifier = modifier - ){ - if (member.canEdit) { - Icon( - painter = painterResource(R.drawable.ic_circle_dots_custom), - tint = LocalColors.current.text, - contentDescription = stringResource(R.string.AccessibilityId_sessionSettings) - ) - } - } + onClick = onClick, + modifier = modifier, + enabled = true, + selected = selected + ) } @Preview @@ -460,7 +458,7 @@ private fun EditGroupPreviewSheet() { showAsAdmin = false, showProBadge = true, clickable = true, - statusLabel = "Invited" + statusLabel = "Invited", ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -529,7 +527,7 @@ private fun EditGroupPreviewSheet() { onSearchQueryChanged = { }, onSearchFocusChanged = { }, searchFocused = false, - onSearchQueryClear = {}, + onSearchQueryClear = {} ) } } @@ -562,7 +560,7 @@ private fun EditGroupEditNamePreview( showAsAdmin = false, showProBadge = true, clickable = true, - statusLabel = "Invited" + statusLabel = "Invited", ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), From 73554cce050ca4553f2fa6afa0abe4c5bb74af9a Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 3 Nov 2025 18:37:58 +0800 Subject: [PATCH 16/50] Initial bottom footer action --- .../securesms/groups/EditGroupViewModel.kt | 85 ++++++++++++++++++ .../groups/compose/EditGroupScreen.kt | 86 ++++++++++++++++++- .../groups/compose/InviteContactsScreen.kt | 4 +- .../thoughtcrime/securesms/ui/Components.kt | 14 +-- 4 files changed, 177 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index b2fe42e1a4..b4e4ffa556 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.ui.platform.LocalResources import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -14,6 +15,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -27,6 +30,9 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.groups.SelectContactsViewModel.CollapsibleFooterState +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUtils @@ -75,6 +81,69 @@ class EditGroupViewModel @AssistedInject constructor( private val _mutableSelectedMemberAccountIds = MutableStateFlow(emptySet()) val selectedMemberAccountIds: StateFlow> = _mutableSelectedMemberAccountIds + val trayItems : List by lazy { + listOf( + CollapsibleFooterItemData( + label = GetString(context.resources.getQuantityString(R.plurals.resendInvite, selectedMemberAccountIds.value.size)), + buttonLabel = GetString(context.getString(R.string.resend)), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString(context.resources.getQuantityString(R.plurals.removeMember, selectedMemberAccountIds.value.size)), + buttonLabel = GetString(context.getString(R.string.remove)), + isDanger = false, + onClick = { } + ) + ) + } + + private val footerCollapsed = MutableStateFlow(false) + + val collapsibleFooterState: StateFlow = + combine(_mutableSelectedMemberAccountIds, footerCollapsed) { selected, isCollapsed -> + val count = selected.size + val visible = count > 0 + val title = if (count == 0) GetString("") + else GetString( + context.resources.getQuantityString(R.plurals.memberSelected, count, count) + ) + + // build tray items + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.resendInvite, count, count) + ), + buttonLabel = GetString(context.getString(R.string.resend)), + isDanger = false, + onClick = { + selected.forEach { onResendInviteClicked(it) } + } + ), + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.removeMember, count, count) + ), + buttonLabel = GetString(context.getString(R.string.remove)), + isDanger = true, + onClick = { + selected.forEach { onRemoveContact(it, removeMessages = false) } + } + ) + ) + + CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title, + footerActionItems = trayItems + ) + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState()) + + fun onMemberItemClicked(accountId: AccountId) { val newSet = _mutableSelectedMemberAccountIds.value.toHashSet() if (!newSet.remove(accountId)) { @@ -204,6 +273,22 @@ class EditGroupViewModel @AssistedInject constructor( _clickedMember.value = null } + fun clearSelection(){ + _mutableSelectedMemberAccountIds.value = emptySet() + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + + 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 096840309e..ee50bd32e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -21,8 +22,10 @@ import androidx.compose.foundation.layout.heightIn 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api @@ -56,9 +59,13 @@ import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_K import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.EditGroupViewModel +import org.thoughtcrime.securesms.groups.EditGroupViewModel.CollapsibleFooterState import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.ui.AlertDialog 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.DialogButtonData import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString @@ -107,6 +114,9 @@ fun EditGroupScreen( showLoading = viewModel.inProgress.collectAsState().value, searchQuery= viewModel.searchQuery.collectAsState().value, searchFocused = viewModel.searchFocused.collectAsState().value, + data = viewModel.collapsibleFooterState.collectAsState().value, + onToggleFooter = viewModel::toggleFooter, + onCloseFooter = viewModel::clearSelection, onSearchQueryChanged = viewModel::onSearchQueryChanged, onSearchFocusChanged = viewModel::onSearchFocusChanged, onSearchQueryClear = {viewModel.onSearchQueryChanged("") } @@ -127,6 +137,9 @@ fun EditGroup( onSearchFocusChanged : (isFocused : Boolean) -> Unit, searchFocused : Boolean, searchQuery: String, + data: CollapsibleFooterState, + onToggleFooter: () -> Unit, + onCloseFooter: () -> Unit, onSearchQueryChanged: (String) -> Unit, onSearchQueryClear: () -> Unit, hideActionSheet: () -> Unit, @@ -163,6 +176,25 @@ fun EditGroup( onBack = onBack, ) }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = data.footerActionTitle, + collapsed = data.collapsed, + visible = data.visible, + items = data.footerActionItems + ), + onCollapsedClicked = onToggleFooter, + onClosedClicked = onCloseFooter + ) + } + }, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddingValues -> Column(modifier = Modifier @@ -224,7 +256,7 @@ fun EditGroup( query = searchQuery, onValueChanged = onSearchQueryChanged, onClear = onSearchQueryClear, - placeholder = "Search", + placeholder = if(searchFocused) "" else LocalResources.current.getString(R.string.search), enabled = true, isFocused = searchFocused, modifier = Modifier.padding(horizontal =LocalDimensions.current.smallSpacing), @@ -437,6 +469,24 @@ fun EditMemberItem( @Preview @Composable private fun EditGroupPreviewSheet() { + val title = GetString("3 Members Selected") + + // build tray items + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("Resend"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("Remove"), + isDanger = true, + onClick = { } + ) + ) + PreviewTheme { val oneMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), @@ -527,13 +577,21 @@ private fun EditGroupPreviewSheet() { onSearchQueryChanged = { }, onSearchFocusChanged = { }, searchFocused = false, - onSearchQueryClear = {} + onSearchQueryClear = {}, + data = CollapsibleFooterState( + visible = true, + collapsed = false, + footerActionTitle = title, + footerActionItems = trayItems + ), + onToggleFooter = {}, + onCloseFooter = {}, + selectedAccountIds = emptySet() ) } } - @Preview @Composable private fun EditGroupEditNamePreview( @@ -628,6 +686,28 @@ private fun EditGroupEditNamePreview( onSearchFocusChanged = {}, searchFocused = true, onSearchQueryClear = {}, + data = CollapsibleFooterState( + visible = true, + collapsed = false, + footerActionTitle = GetString("3 Members Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + ), + onToggleFooter = {}, + onCloseFooter = {}, + selectedAccountIds = emptySet(), ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index b0ece45a94..51db502057 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -88,12 +88,12 @@ fun InviteContacts( onToggleFooter: () -> Unit, onCloseFooter: () -> Unit, ) { - val colors = LocalColors.current + val trayItems = listOf( CollapsibleFooterItemData( label = GetString(LocalResources.current.getString(R.string.membersInvite)), buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)), - buttonColor = colors.accent, + isDanger = false, onClick = { onDoneClicked() } ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 0f27c79fc1..bc5082bdb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -992,7 +992,7 @@ private fun CollapsibleFooterActions( SubcomposeLayout { parentConstraints -> val measurables = subcompose("measureButtons") { items.forEach { item -> - SlimFillButtonRect(item.buttonLabel.string(), color = item.buttonColor) {} + SlimFillButtonRect(item.buttonLabel.string(), color = LocalColors.current.accent) {} } } val placeables = measurables.map { m -> @@ -1043,7 +1043,7 @@ private fun CollapsibleFooterActions( SlimFillButtonRect( modifier = if (single) Modifier else Modifier.fillMaxWidth(), text = item.buttonLabel.string(), - color = item.buttonColor + color = if(item.isDanger) LocalColors.current.danger else LocalColors.current.accent ) { item.onClick() } } } @@ -1063,7 +1063,7 @@ data class CollapsibleFooterActionData( data class CollapsibleFooterItemData( val label: GetString, val buttonLabel: GetString, - val buttonColor: Color, + val isDanger: Boolean, val onClick: () -> Unit ) @@ -1078,13 +1078,13 @@ fun PreviewCollapsibleActionTray( CollapsibleFooterItemData( label = GetString("Invite "), buttonLabel = GetString("Invite"), - buttonColor = LocalColors.current.accent, + isDanger = false, onClick = {} ), CollapsibleFooterItemData( label = GetString("Delete"), buttonLabel = GetString("2"), - buttonColor = LocalColors.current.danger, + isDanger = true, onClick = {} ) ) @@ -1110,13 +1110,13 @@ fun PreviewCollapsibleActionTrayLongText( CollapsibleFooterItemData( label = GetString("Looooooooooooooooooooooooooooooooooooooooooooooooooooooooong"), buttonLabel = GetString("Long Looooooooooooooooooooong"), - buttonColor = LocalColors.current.accent, + isDanger = false, onClick = {} ), CollapsibleFooterItemData( label = GetString("Delete"), buttonLabel = GetString("Delete"), - buttonColor = LocalColors.current.danger, + isDanger = true, onClick = {} ) ) From fd5d21b239bd697b7ac84b30940a8008676cb319 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 4 Nov 2025 17:01:25 +0800 Subject: [PATCH 17/50] Initial resend invite --- .../messaging/groups/GroupManagerV2.kt | 6 + .../securesms/groups/EditGroupViewModel.kt | 45 ++--- .../securesms/groups/GroupManagerV2Impl.kt | 173 +++++++++++++++--- .../groups/compose/EditGroupScreen.kt | 10 +- 4 files changed, 178 insertions(+), 56 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 b8ea9990ec..097341f1e9 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 @@ -6,6 +6,7 @@ import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.MemberInvite /** * Business logic handling group v2 operations like inviting members, @@ -25,6 +26,11 @@ interface GroupManagerV2 { isReinvite: Boolean, // Whether this comes from a re-invite ) + suspend fun reinviteMembers( + group: AccountId, + invites: List + ) + suspend fun removeMembers( groupAccountId: AccountId, removedMembers: List, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index b4e4ffa556..c54ed5fc41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.all import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -81,23 +82,6 @@ class EditGroupViewModel @AssistedInject constructor( private val _mutableSelectedMemberAccountIds = MutableStateFlow(emptySet()) val selectedMemberAccountIds: StateFlow> = _mutableSelectedMemberAccountIds - val trayItems : List by lazy { - listOf( - CollapsibleFooterItemData( - label = GetString(context.resources.getQuantityString(R.plurals.resendInvite, selectedMemberAccountIds.value.size)), - buttonLabel = GetString(context.getString(R.string.resend)), - isDanger = false, - onClick = {} - ), - CollapsibleFooterItemData( - label = GetString(context.resources.getQuantityString(R.plurals.removeMember, selectedMemberAccountIds.value.size)), - buttonLabel = GetString(context.getString(R.string.remove)), - isDanger = false, - onClick = { } - ) - ) - } - private val footerCollapsed = MutableStateFlow(false) val collapsibleFooterState: StateFlow = @@ -117,9 +101,7 @@ class EditGroupViewModel @AssistedInject constructor( ), buttonLabel = GetString(context.getString(R.string.resend)), isDanger = false, - onClick = { - selected.forEach { onResendInviteClicked(it) } - } + onClick = { onResendInviteClicked() } ), CollapsibleFooterItemData( label = GetString( @@ -175,7 +157,8 @@ class EditGroupViewModel @AssistedInject constructor( } } - fun onResendInviteClicked(contactSessionId: AccountId) { + fun onResendInviteClicked() { + if (selectedMemberAccountIds.value.isEmpty()) return performGroupOperation( showLoading = false, errorMessage = { err -> @@ -186,15 +169,19 @@ class EditGroupViewModel @AssistedInject constructor( } } ) { - val historyShared = configFactory.withGroupConfigs(groupId) { - it.groupMembers.getOrNull(contactSessionId.hexString) - }?.supplement == true + // Look up current member configs once + val membersCfg = configFactory.withGroupConfigs(groupId) { it.groupMembers } - groupManager.inviteMembers( - groupId, - listOf(contactSessionId), - shareHistory = historyShared, - isReinvite = true, + // Build per-member invites with their own shareHistory flag + val invites = selectedMemberAccountIds.value.distinct().map { accountId -> + val shareHistory = membersCfg?.getOrNull(accountId.hexString)?.supplement == true + MemberInvite(id = accountId, shareHistory = shareHistory) + } + + // Reinvite with per-member shareHistory + groupManager.reinviteMembers( + group = groupId, + invites = invites ) } } 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 c5e3acf8dc..22a2bd39cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -4,7 +4,6 @@ import android.content.Context import com.google.protobuf.ByteString import com.squareup.phrase.Phrase import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async @@ -70,9 +69,12 @@ import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.map private const val TAG = "GroupManagerV2Impl" +data class MemberInvite(val id: AccountId, val shareHistory: Boolean) + @Singleton class GroupManagerV2Impl @Inject constructor( private val storage: StorageProtocol, @@ -218,45 +220,173 @@ class GroupManagerV2Impl @Inject constructor( } } - override suspend fun inviteMembers( group: AccountId, newMembers: List, shareHistory: Boolean, isReinvite: Boolean - ): Unit = scope.launchAndWait(group, "Invite members") { + ): Unit = inviteMembersInternal( + group = group, + memberInvites = newMembers.map { MemberInvite(it, shareHistory) }, + isReinvite = isReinvite + ) + + override suspend fun reinviteMembers( + group: AccountId, + invites: List + ): Unit = inviteMembersInternal( + group = group, + memberInvites = invites, + isReinvite = true + ) + +// override suspend fun inviteMembers( +// group: AccountId, +// newMembers: List, +// shareHistory: Boolean, +// isReinvite: Boolean +// ): Unit = scope.launchAndWait(group, "Invite members") { +// val adminKey = requireAdminAccess(group) +// val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) +// +// val batchRequests = mutableListOf() +// +// // Construct the new members in our config +// val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> +// // Construct the new members in the config +// for (newMember in newMembers) { +// val toSet = configs.groupMembers.get(newMember.hexString) +// ?.also { existing -> +// val status = configs.groupMembers.status(existing) +// if (status == GroupMember.Status.INVITE_FAILED || status == GroupMember.Status.INVITE_SENT) { +// existing.setSupplement(shareHistory) +// } +// } +// ?: configs.groupMembers.getOrConstruct(newMember.hexString).also { member -> +// val contact = configFactory.withUserConfigs { configs -> +// configs.contacts.get(newMember.hexString) +// } +// +// member.setName(contact?.name.orEmpty()) +// member.setProfilePic(contact?.profilePicture ?: UserPic.DEFAULT) +// member.setSupplement(shareHistory) +// } +// +// toSet.setInvited() +// configs.groupMembers.set(toSet) +// } +// +// if (shareHistory) { +// val memberKey = configs.groupKeys.supplementFor(newMembers.map { it.hexString }) +// batchRequests.add( +// SnodeAPI.buildAuthenticatedStoreBatchInfo( +// namespace = Namespace.GROUP_KEYS(), +// message = SnodeMessage( +// recipient = group.hexString, +// data = Base64.encodeBytes(memberKey), +// ttl = SnodeMessage.CONFIG_TTL, +// timestamp = clock.currentTimeMills(), +// ), +// auth = groupAuth, +// ) +// ) +// } +// +// configs.rekey() +// newMembers.map { configs.groupKeys.getSubAccountToken(it.hexString) } +// } +// +// // Call un-revocate API on new members, in case they have been removed before +// batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( +// groupAdminAuth = groupAuth, +// subAccountTokens = subAccountTokens +// ) +// +// // Call the API +// try { +// val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() +// val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) +// +// // Make sure every request is successful +// response.requireAllRequestsSuccessful("Failed to invite members") +// +// // Wait for the group configs to be pushed +// configFactory.waitUntilGroupConfigsPushed(group) +// } catch (e: Exception) { +// // Update every member's status to "invite failed" and return group name +// val groupName = configFactory.withMutableGroupConfigs(group) { configs -> +// for (newMember in newMembers) { +// configs.groupMembers.get(newMember.hexString)?.apply { +// setInviteFailed() +// configs.groupMembers.set(this) +// } +// } +// +// configs.groupInfo.getName().orEmpty() +// } +// +// Log.w(TAG, "Failed to invite members to group $group", e) +// +// throw GroupInviteException( +// isPromotion = false, +// inviteeAccountIds = newMembers.map { it.hexString }, +// groupName = groupName, +// underlying = e +// ) +// } finally { +// // Send a group update message to the group telling members someone has been invited +// if (!isReinvite) { +// sendGroupUpdateForAddingMembers(group, adminKey, newMembers) +// } +// } +// +// // Send the invitation message to the new members +// JobQueue.shared.add( +// InviteContactsJob( +// group.hexString, +// newMembers.map { it.hexString }.toTypedArray() +// ) +// ) +// } + + private suspend fun inviteMembersInternal( + group: AccountId, + memberInvites: List, + isReinvite: Boolean + ): Unit = scope.launchAndWait(group, if (isReinvite) "Reinvite members" else "Invite members") { val adminKey = requireAdminAccess(group) val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) val batchRequests = mutableListOf() - // Construct the new members in our config val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> - // Construct the new members in the config - for (newMember in newMembers) { - val toSet = configs.groupMembers.get(newMember.hexString) + val shareHistoryHexes = mutableListOf() + + for ((id, shareHistory) in memberInvites) { + val hex = id.hexString + + val toSet = configs.groupMembers.get(hex) ?.also { existing -> val status = configs.groupMembers.status(existing) if (status == GroupMember.Status.INVITE_FAILED || status == GroupMember.Status.INVITE_SENT) { existing.setSupplement(shareHistory) } } - ?: configs.groupMembers.getOrConstruct(newMember.hexString).also { member -> - val contact = configFactory.withUserConfigs { configs -> - configs.contacts.get(newMember.hexString) - } - + ?: configs.groupMembers.getOrConstruct(hex).also { member -> + val contact = configFactory.withUserConfigs { it.contacts.get(hex) } member.setName(contact?.name.orEmpty()) member.setProfilePic(contact?.profilePicture ?: UserPic.DEFAULT) member.setSupplement(shareHistory) } + if (shareHistory) shareHistoryHexes += hex + toSet.setInvited() configs.groupMembers.set(toSet) } - if (shareHistory) { - val memberKey = configs.groupKeys.supplementFor(newMembers.map { it.hexString }) + if (shareHistoryHexes.isNotEmpty()) { + val memberKey = configs.groupKeys.supplementFor(shareHistoryHexes) batchRequests.add( SnodeAPI.buildAuthenticatedStoreBatchInfo( namespace = Namespace.GROUP_KEYS(), @@ -272,7 +402,7 @@ class GroupManagerV2Impl @Inject constructor( } configs.rekey() - newMembers.map { configs.groupKeys.getSubAccountToken(it.hexString) } + memberInvites.map { configs.groupKeys.getSubAccountToken(it.id.hexString) } } // Call un-revocate API on new members, in case they have been removed before @@ -281,7 +411,6 @@ class GroupManagerV2Impl @Inject constructor( subAccountTokens = subAccountTokens ) - // Call the API try { val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) @@ -294,13 +423,12 @@ class GroupManagerV2Impl @Inject constructor( } catch (e: Exception) { // Update every member's status to "invite failed" and return group name val groupName = configFactory.withMutableGroupConfigs(group) { configs -> - for (newMember in newMembers) { - configs.groupMembers.get(newMember.hexString)?.apply { + for ((id, _) in memberInvites) { + configs.groupMembers.get(id.hexString)?.apply { setInviteFailed() configs.groupMembers.set(this) } } - configs.groupInfo.getName().orEmpty() } @@ -308,14 +436,14 @@ class GroupManagerV2Impl @Inject constructor( throw GroupInviteException( isPromotion = false, - inviteeAccountIds = newMembers.map { it.hexString }, + inviteeAccountIds = memberInvites.map { it.id.hexString }, groupName = groupName, underlying = e ) } finally { // Send a group update message to the group telling members someone has been invited if (!isReinvite) { - sendGroupUpdateForAddingMembers(group, adminKey, newMembers) + sendGroupUpdateForAddingMembers(group, adminKey, memberInvites.map { it.id }) } } @@ -323,11 +451,12 @@ class GroupManagerV2Impl @Inject constructor( JobQueue.shared.add( InviteContactsJob( group.hexString, - newMembers.map { it.hexString }.toTypedArray() + memberInvites.map { it.id.hexString }.toTypedArray() ) ) } + /** * Send a group update message to the group telling members someone has been invited. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index ee50bd32e0..23205616a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -98,7 +98,7 @@ fun EditGroupScreen( EditGroup( onBack = onBack, onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, - onResendInviteClick = viewModel::onResendInviteClicked, +// onResendInviteClick = viewModel::onResendInviteClicked, onPromoteClick = viewModel::onPromoteContact, onRemoveClick = viewModel::onRemoveContact, members = viewModel.nonAdminMembers.collectAsState().value, @@ -129,7 +129,7 @@ fun EditGroupScreen( fun EditGroup( onBack: () -> Unit, onAddMemberClick: () -> Unit, - onResendInviteClick: (accountId: AccountId) -> Unit, +// onResendInviteClick: (accountId: AccountId) -> Unit, onResendPromotionClick: (accountId: AccountId) -> Unit, onPromoteClick: (accountId: AccountId) -> Unit, onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit, @@ -300,7 +300,7 @@ fun EditGroup( hideActionSheet() }, onResendInvite = { - onResendInviteClick(clickedMember.accountId) +// onResendInviteClick(clickedMember.accountId) hideActionSheet() }, onResendPromotion = { @@ -560,7 +560,7 @@ private fun EditGroupPreviewSheet() { EditGroup( onBack = {}, onAddMemberClick = {}, - onResendInviteClick = {}, +// onResendInviteClick = {}, onPromoteClick = {}, onRemoveClick = { _, _ -> }, members = listOf(oneMember, twoMember, threeMember), @@ -668,7 +668,7 @@ private fun EditGroupEditNamePreview( EditGroup( onBack = {}, onAddMemberClick = {}, - onResendInviteClick = {}, +// onResendInviteClick = {}, onPromoteClick = {}, onRemoveClick = { _, _ -> }, members = listOf(oneMember, twoMember, threeMember), From 3b6fb51ad3042c63cfd2442ab6861d79032f4885 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 4 Nov 2025 17:35:20 +0800 Subject: [PATCH 18/50] Update state when clicking resend --- .../securesms/groups/EditGroupViewModel.kt | 3 +++ .../securesms/groups/compose/EditGroupScreen.kt | 4 ---- .../org/thoughtcrime/securesms/ui/Components.kt | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index c54ed5fc41..1eb5c0b697 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -178,6 +178,9 @@ class EditGroupViewModel @AssistedInject constructor( MemberInvite(id = accountId, shareHistory = shareHistory) } + onSearchFocusChanged(false) + clearSelection() + // Reinvite with per-member shareHistory groupManager.reinviteMembers( group = groupId, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 23205616a8..7e26a93f38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -98,7 +98,6 @@ fun EditGroupScreen( EditGroup( onBack = onBack, onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, -// onResendInviteClick = viewModel::onResendInviteClicked, onPromoteClick = viewModel::onPromoteContact, onRemoveClick = viewModel::onRemoveContact, members = viewModel.nonAdminMembers.collectAsState().value, @@ -129,7 +128,6 @@ fun EditGroupScreen( fun EditGroup( onBack: () -> Unit, onAddMemberClick: () -> Unit, -// onResendInviteClick: (accountId: AccountId) -> Unit, onResendPromotionClick: (accountId: AccountId) -> Unit, onPromoteClick: (accountId: AccountId) -> Unit, onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit, @@ -560,7 +558,6 @@ private fun EditGroupPreviewSheet() { EditGroup( onBack = {}, onAddMemberClick = {}, -// onResendInviteClick = {}, onPromoteClick = {}, onRemoveClick = { _, _ -> }, members = listOf(oneMember, twoMember, threeMember), @@ -668,7 +665,6 @@ private fun EditGroupEditNamePreview( EditGroup( onBack = {}, onAddMemberClick = {}, -// onResendInviteClick = {}, onPromoteClick = {}, onRemoveClick = { _, _ -> }, members = listOf(oneMember, twoMember, threeMember), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index bc5082bdb5..1e33a916c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -82,6 +82,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -102,6 +103,7 @@ import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -781,6 +783,19 @@ fun SearchBarWithCancel( backgroundColor: Color = LocalColors.current.backgroundSecondary, ) { val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + + // When the parent toggles isFocused, request or clear focus accordingly + LaunchedEffect(isFocused) { + if (isFocused) { + focusRequester.requestFocus() + keyboard?.show() + } else { + focusManager.clearFocus(force = true) + keyboard?.hide() + } + } Row( verticalAlignment = Alignment.CenterVertically, From 73f23ea9d82858c77536d7432d86d6cba1f1acf8 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 4 Nov 2025 17:44:55 +0800 Subject: [PATCH 19/50] Fix search state after resend --- .../java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index 1eb5c0b697..082a64884d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -179,6 +179,7 @@ class EditGroupViewModel @AssistedInject constructor( } onSearchFocusChanged(false) + onSearchQueryChanged("") clearSelection() // Reinvite with per-member shareHistory From 23ccf9c234fde337dc8536fd769de7c66caa9690 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 5 Nov 2025 14:35:39 +0800 Subject: [PATCH 20/50] New error and toast strings, code cleanup --- .../messaging/groups/GroupInviteException.kt | 14 ++++++++++--- .../messaging/jobs/InviteContactsJob.kt | 3 ++- .../securesms/groups/EditGroupViewModel.kt | 10 ++++++++++ .../securesms/groups/GroupManagerV2Impl.kt | 8 +++++--- .../groups/compose/EditGroupScreen.kt | 20 +++++++++++++++++-- 5 files changed, 46 insertions(+), 9 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 a4c2bc8f49..d2a2c5239f 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 @@ -23,6 +23,7 @@ class GroupInviteException( val isPromotion: Boolean, val inviteeAccountIds: List, val groupName: String, + val isReinvite: Boolean, underlying: Throwable ) : RuntimeException(underlying) { init { @@ -41,19 +42,26 @@ class GroupInviteException( val third = inviteeAccountIds.getOrNull(2)?.let(getInviteeName) if (second != null && third != null) { - return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else R.string.groupInviteFailedMultiple) + val errorString = + if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else + if (isReinvite) R.string.failedResendInviteMultiple else R.string.groupInviteFailedMultiple + return Phrase.from(context, errorString) .put(NAME_KEY, first) .put(COUNT_KEY, inviteeAccountIds.size - 1) .put(GROUP_NAME_KEY, groupName) .format() } else if (second != null) { - return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else R.string.groupInviteFailedTwo) + val errorString = if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else + if (isReinvite) R.string.failedResendInviteTwo else R.string.groupInviteFailedTwo + return Phrase.from(context, errorString) .put(NAME_KEY, first) .put(OTHER_NAME_KEY, second) .put(GROUP_NAME_KEY, groupName) .format() } else { - return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescription else R.string.groupInviteFailedUser) + val errorString = if (isPromotion) R.string.adminPromotionFailedDescription else + if (isReinvite) R.string.failedResendInvite else R.string.groupInviteFailedUser + return Phrase.from(context, errorString) .put(NAME_KEY, first) .put(GROUP_NAME_KEY, groupName) .format() diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 1a4c5cc9f9..9109309e93 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -22,7 +22,7 @@ import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateM import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array) : Job { +class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array, val isReinvite : Boolean) : Job { companion object { const val KEY = "InviteContactJob" @@ -122,6 +122,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< inviteeAccountIds = failures.map { it.first }, groupName = groupName.orEmpty(), underlying = firstError, + isReinvite = isReinvite ).format(MessagingModuleConfiguration.shared.context, MessagingModuleConfiguration.shared.recipientRepository).let { withContext(Dispatchers.Main) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index 082a64884d..d5cc39949c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -71,6 +71,9 @@ class EditGroupViewModel @AssistedInject constructor( private val mutableError = MutableStateFlow(null) val error: StateFlow get() = mutableError + private val _mutableResendString = MutableStateFlow(null) + val resendString: StateFlow = _mutableResendString + // Output: val excludingAccountIDsFromContactSelection: Set get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId.hexString }.orEmpty() @@ -182,6 +185,11 @@ class EditGroupViewModel @AssistedInject constructor( onSearchQueryChanged("") clearSelection() + _mutableResendString.value = context.resources.getQuantityString( + R.plurals.resendingInvite, + selectedMemberAccountIds.value.size + ) + // Reinvite with per-member shareHistory groupManager.reinviteMembers( group = groupId, @@ -272,6 +280,8 @@ class EditGroupViewModel @AssistedInject constructor( footerCollapsed.update { !it } } + fun onDismissResend() { _mutableResendString.value = null } + data class CollapsibleFooterState( val visible: Boolean = false, 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 22a2bd39cc..1fa5c146d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -205,7 +205,8 @@ class GroupManagerV2Impl @Inject constructor( JobQueue.shared.add( InviteContactsJob( groupSessionId = groupId.hexString, - memberSessionIds = members.map { it.hexString }.toTypedArray() + memberSessionIds = members.map { it.hexString }.toTypedArray(), + false ) ) @@ -438,7 +439,8 @@ class GroupManagerV2Impl @Inject constructor( isPromotion = false, inviteeAccountIds = memberInvites.map { it.id.hexString }, groupName = groupName, - underlying = e + underlying = e, + isReinvite = isReinvite ) } finally { // Send a group update message to the group telling members someone has been invited @@ -451,7 +453,7 @@ class GroupManagerV2Impl @Inject constructor( JobQueue.shared.add( InviteContactsJob( group.hexString, - memberInvites.map { it.id.hexString }.toTypedArray() + memberInvites.map { it.id.hexString }.toTypedArray(), isReinvite ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 7e26a93f38..88470421c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.groups.compose +import android.R.attr.data import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -50,6 +51,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.squareup.phrase.Phrase import network.loki.messenger.BuildConfig import network.loki.messenger.R @@ -107,6 +109,8 @@ fun EditGroupScreen( onResendPromotionClick = viewModel::onResendPromotionClicked, showingError = viewModel.error.collectAsState().value, onErrorDismissed = viewModel::onDismissError, + showingResend = viewModel.resendString.collectAsState().value, + onResendDismissed = viewModel::onDismissResend, onMemberClicked = viewModel::onMemberItemClicked, hideActionSheet = viewModel::hideActionBottomSheet, clickedMember = viewModel.clickedMember.collectAsState().value, @@ -147,6 +151,8 @@ fun EditGroup( selectedAccountIds: Set = emptySet(), showAddMembers: Boolean, showingError: String?, + showingResend:String?, + onResendDismissed: () -> Unit, showLoading: Boolean, onErrorDismissed: () -> Unit, ) { @@ -287,6 +293,7 @@ fun EditGroup( } if (clickedMember != null) { + // TODO: Delete this in favor of the collapsible footer MemberActionSheet( onDismissRequest = hideActionSheet, onRemove = { @@ -298,7 +305,6 @@ fun EditGroup( hideActionSheet() }, onResendInvite = { -// onResendInviteClick(clickedMember.accountId) hideActionSheet() }, onResendPromotion = { @@ -332,6 +338,12 @@ fun EditGroup( onErrorDismissed() } } + LaunchedEffect(showingResend) { + if (showingResend != null) { + Toast.makeText(context, showingResend, Toast.LENGTH_SHORT).show() + onResendDismissed() + } + } } @Composable @@ -583,7 +595,9 @@ private fun EditGroupPreviewSheet() { ), onToggleFooter = {}, onCloseFooter = {}, - selectedAccountIds = emptySet() + selectedAccountIds = emptySet(), + showingResend ="Resending Invite", + onResendDismissed = {} ) } } @@ -704,6 +718,8 @@ private fun EditGroupEditNamePreview( onToggleFooter = {}, onCloseFooter = {}, selectedAccountIds = emptySet(), + showingResend ="Resending Invite", + onResendDismissed = {} ) } } \ No newline at end of file From b7386e6024038b4fc7e907280fe2303646ea5f81 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 5 Nov 2025 19:49:04 +0800 Subject: [PATCH 21/50] Remove members dialog --- .../securesms/groups/EditGroupViewModel.kt | 124 ++++++++++--- .../groups/compose/EditGroupScreen.kt | 172 ++++++++++-------- 2 files changed, 195 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index d5cc39949c..791e8952f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.groups import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.ui.platform.LocalResources import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -15,7 +15,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.all import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -29,9 +28,13 @@ 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 +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.session.libsession.utilities.recipients.displayName import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.groups.SelectContactsViewModel.CollapsibleFooterState import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUtils @@ -82,13 +85,13 @@ class EditGroupViewModel @AssistedInject constructor( private val mutableSearchFocused = MutableStateFlow(false) val searchFocused: StateFlow get() = mutableSearchFocused - private val _mutableSelectedMemberAccountIds = MutableStateFlow(emptySet()) - val selectedMemberAccountIds: StateFlow> = _mutableSelectedMemberAccountIds + private val _mutableSelectedMembers = MutableStateFlow(emptySet()) + val selectedMembers: StateFlow> = _mutableSelectedMembers private val footerCollapsed = MutableStateFlow(false) val collapsibleFooterState: StateFlow = - combine(_mutableSelectedMemberAccountIds, footerCollapsed) { selected, isCollapsed -> + combine(_mutableSelectedMembers, footerCollapsed) { selected, isCollapsed -> val count = selected.size val visible = count > 0 val title = if (count == 0) GetString("") @@ -112,9 +115,7 @@ class EditGroupViewModel @AssistedInject constructor( ), buttonLabel = GetString(context.getString(R.string.remove)), isDanger = true, - onClick = { - selected.forEach { onRemoveContact(it, removeMessages = false) } - } + onClick = {onCommand(Commands.ShowRemoveDialog)} ) ) @@ -128,13 +129,62 @@ class EditGroupViewModel @AssistedInject constructor( .distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState()) + private val showRemoveMember = MutableStateFlow(false) + val removeMembersState: StateFlow = + combine( + showRemoveMember, + selectedMembers, + groupName + ) { showRemove, selected, group -> + val count = selected.size + val firstMember = selected.firstOrNull() + + val body = + when (count) { + 1 -> { + Phrase.from(context, R.string.groupRemoveDescription) + .put(NAME_KEY, firstMember?.name) + .put(GROUP_NAME_KEY, group) + .format() + } + + 2 -> { + val secondMember = selected.elementAtOrNull(1)?.name + Phrase.from(context, R.string.groupRemoveDescriptionTwo) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .put(GROUP_NAME_KEY, group) + .format() + } + + 0 -> "" + else -> { + Phrase.from(context, R.string.groupRemoveDescriptionMultiple) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .put(GROUP_NAME_KEY, group) + .format() + } + } + val removeMemberOnly = + context.resources.getQuantityString(R.plurals.removeMember, count, count) + val removeMessages = + context.resources.getQuantityString(R.plurals.removeMemberMessages, count, count) + + RemoveMembersState( + visible = showRemove, + removeMemberBody = body, + removeMemberText = removeMemberOnly, + removeMessagesText = removeMessages + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, RemoveMembersState()) - fun onMemberItemClicked(accountId: AccountId) { - val newSet = _mutableSelectedMemberAccountIds.value.toHashSet() - if (!newSet.remove(accountId)) { - newSet.add(accountId) + fun onMemberItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedMembers.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) } - _mutableSelectedMemberAccountIds.value = newSet + _mutableSelectedMembers.value = newSet } fun onSearchFocusChanged(isFocused :Boolean){ mutableSearchFocused.value = isFocused @@ -161,7 +211,7 @@ class EditGroupViewModel @AssistedInject constructor( } fun onResendInviteClicked() { - if (selectedMemberAccountIds.value.isEmpty()) return + if (selectedMembers.value.isEmpty()) return performGroupOperation( showLoading = false, errorMessage = { err -> @@ -176,9 +226,9 @@ class EditGroupViewModel @AssistedInject constructor( val membersCfg = configFactory.withGroupConfigs(groupId) { it.groupMembers } // Build per-member invites with their own shareHistory flag - val invites = selectedMemberAccountIds.value.distinct().map { accountId -> - val shareHistory = membersCfg?.getOrNull(accountId.hexString)?.supplement == true - MemberInvite(id = accountId, shareHistory = shareHistory) + val invites = selectedMembers.value.distinct().map { member -> + val shareHistory = membersCfg?.getOrNull(member.accountId.hexString)?.supplement == true + MemberInvite(id = member.accountId, shareHistory = shareHistory) } onSearchFocusChanged(false) @@ -187,7 +237,7 @@ class EditGroupViewModel @AssistedInject constructor( _mutableResendString.value = context.resources.getQuantityString( R.plurals.resendingInvite, - selectedMemberAccountIds.value.size + selectedMembers.value.size ) // Reinvite with per-member shareHistory @@ -204,11 +254,11 @@ class EditGroupViewModel @AssistedInject constructor( } } - fun onRemoveContact(contactSessionId: AccountId, removeMessages: Boolean) { + fun onRemoveContact(removeMessages: Boolean) { performGroupOperation(showLoading = false) { groupManager.removeMembers( groupAccountId = groupId, - removedMembers = listOf(contactSessionId), + removedMembers = selectedMembers.value.map { it.accountId }, removeMessages = removeMessages ) } @@ -273,7 +323,7 @@ class EditGroupViewModel @AssistedInject constructor( } fun clearSelection(){ - _mutableSelectedMemberAccountIds.value = emptySet() + _mutableSelectedMembers.value = emptySet() } fun toggleFooter() { @@ -282,6 +332,23 @@ class EditGroupViewModel @AssistedInject constructor( fun onDismissResend() { _mutableResendString.value = null } + private fun toggleRemoveDialog(visible : Boolean){ + showRemoveMember.value = visible + } + + fun onCommand(command : Commands){ + when (command){ + is Commands.ShowRemoveDialog -> { + toggleRemoveDialog(true) + } + is Commands.DismissRemoveDialog -> { + toggleRemoveDialog(false) + } + is Commands.RemoveMembers -> { + onRemoveContact(command.removeMessages) + } + } + } data class CollapsibleFooterState( val visible: Boolean = false, @@ -290,6 +357,13 @@ class EditGroupViewModel @AssistedInject constructor( val footerActionItems : List = emptyList() ) + data class RemoveMembersState( + val visible : Boolean = false, + val removeMemberBody : CharSequence = "", + val removeMemberText : String = "", + val removeMessagesText : String = "" + ) + data class OptionsItem( val name: String, @DrawableRes val icon: Int, @@ -297,6 +371,12 @@ class EditGroupViewModel @AssistedInject constructor( val onClick: () -> Unit ) + sealed interface Commands { + data object ShowRemoveDialog : Commands + data object DismissRemoveDialog : Commands + data class RemoveMembers(val removeMessages: Boolean) : Commands + } + @AssistedFactory interface Factory { fun create(groupAddress: Address.Group): EditGroupViewModel diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index 88470421c8..9c5b896028 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.groups.compose -import android.R.attr.data import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -8,18 +7,14 @@ 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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope 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.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -30,28 +25,24 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon 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.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Alignment.Companion.CenterVertically 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.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.squareup.phrase.Phrase import network.loki.messenger.BuildConfig import network.loki.messenger.R @@ -73,10 +64,12 @@ 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.RadioOption import org.thoughtcrime.securesms.ui.SearchBarWithCancel import org.thoughtcrime.securesms.ui.components.ActionSheet import org.thoughtcrime.securesms.ui.components.ActionSheetItemData import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.getCellBottomShape import org.thoughtcrime.securesms.ui.getCellTopShape @@ -101,9 +94,8 @@ fun EditGroupScreen( onBack = onBack, onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, onPromoteClick = viewModel::onPromoteContact, - onRemoveClick = viewModel::onRemoveContact, members = viewModel.nonAdminMembers.collectAsState().value, - selectedAccountIds = viewModel.selectedMemberAccountIds.collectAsState().value, + selectedMembers = viewModel.selectedMembers.collectAsState().value, groupName = viewModel.groupName.collectAsState().value, showAddMembers = viewModel.showAddMembers.collectAsState().value, onResendPromotionClick = viewModel::onResendPromotionClicked, @@ -115,14 +107,16 @@ fun EditGroupScreen( hideActionSheet = viewModel::hideActionBottomSheet, clickedMember = viewModel.clickedMember.collectAsState().value, showLoading = viewModel.inProgress.collectAsState().value, - searchQuery= viewModel.searchQuery.collectAsState().value, + searchQuery = viewModel.searchQuery.collectAsState().value, searchFocused = viewModel.searchFocused.collectAsState().value, data = viewModel.collapsibleFooterState.collectAsState().value, onToggleFooter = viewModel::toggleFooter, onCloseFooter = viewModel::clearSelection, onSearchQueryChanged = viewModel::onSearchQueryChanged, onSearchFocusChanged = viewModel::onSearchFocusChanged, - onSearchQueryClear = {viewModel.onSearchQueryChanged("") } + onSearchQueryClear = { viewModel.onSearchQueryChanged("") }, + sendCommands = viewModel::onCommand, + removeMembersData = viewModel.removeMembersState.collectAsState().value ) } @@ -134,8 +128,7 @@ fun EditGroup( onAddMemberClick: () -> Unit, onResendPromotionClick: (accountId: AccountId) -> Unit, onPromoteClick: (accountId: AccountId) -> Unit, - onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit, - onMemberClicked: (accountId: AccountId) -> Unit, + onMemberClicked: (member: GroupMemberState) -> Unit, onSearchFocusChanged : (isFocused : Boolean) -> Unit, searchFocused : Boolean, searchQuery: String, @@ -148,17 +141,19 @@ fun EditGroup( clickedMember: GroupMemberState?, groupName: String, members: List, - selectedAccountIds: Set = emptySet(), + selectedMembers: Set = emptySet(), showAddMembers: Boolean, showingError: String?, showingResend:String?, onResendDismissed: () -> Unit, showLoading: Boolean, onErrorDismissed: () -> Unit, + sendCommands: (command : EditGroupViewModel.Commands) -> Unit, + removeMembersData: EditGroupViewModel.RemoveMembersState ) { - val (showingConfirmRemovingMember, setShowingConfirmRemovingMember) = remember { - mutableStateOf(null) - } +// val (showingConfirmRemovingMember, setShowingConfirmRemovingMember) = remember { +// mutableStateOf(null) +// } val optionsList: List = listOf( EditGroupViewModel.OptionsItem( @@ -278,8 +273,8 @@ fun EditGroup( EditMemberItem( modifier = Modifier.fillMaxWidth(), member = member, - onClick = { onMemberClicked(member.accountId) }, - selected = member.accountId in selectedAccountIds + onClick = { onMemberClicked(member) }, + selected = member in selectedMembers ) } @@ -291,38 +286,11 @@ fun EditGroup( } } } - - if (clickedMember != null) { - // TODO: Delete this in favor of the collapsible footer - MemberActionSheet( - onDismissRequest = hideActionSheet, - onRemove = { - setShowingConfirmRemovingMember(clickedMember) - hideActionSheet() - }, - onPromote = { - onPromoteClick(clickedMember.accountId) - hideActionSheet() - }, - onResendInvite = { - hideActionSheet() - }, - onResendPromotion = { - onResendPromotionClick(clickedMember.accountId) - hideActionSheet() - }, - member = clickedMember, - ) - } - - if (showingConfirmRemovingMember != null) { - ConfirmRemovingMemberDialog( - onDismissRequest = { - setShowingConfirmRemovingMember(null) - }, - onConfirmed = onRemoveClick, - member = showingConfirmRemovingMember, - groupName = groupName, + + if(removeMembersData.visible){ + ShowRemoveMembersDialog( + state = removeMembersData, + sendCommand = sendCommands ) } @@ -346,21 +314,7 @@ fun EditGroup( } } -@Composable -private fun GroupNameContainer(content: @Composable RowScope.() -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 72.dp), - horizontalArrangement = Arrangement.spacedBy( - LocalDimensions.current.xxxsSpacing, - CenterHorizontally - ), - verticalAlignment = CenterVertically, - content = content - ) -} - +//todo : Delete after implementing collapsing bottom @Composable private fun ConfirmRemovingMemberDialog( onConfirmed: (accountId: AccountId, removeMessages: Boolean) -> Unit, @@ -393,6 +347,7 @@ private fun ConfirmRemovingMemberDialog( ) } +// todo : delete after promote admin @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MemberActionSheet( @@ -476,6 +431,63 @@ fun EditMemberItem( ) } +@Composable +fun ShowRemoveMembersDialog( + state: EditGroupViewModel.RemoveMembersState, + modifier: Modifier = Modifier, + sendCommand: (EditGroupViewModel.Commands) -> Unit +) { + var deleteMessages by remember { mutableStateOf(false) } + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(EditGroupViewModel.Commands.DismissRemoveDialog) + }, + title = annotatedStringResource(R.string.remove), + text = annotatedStringResource(state.removeMemberBody), + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(state.removeMemberText), + selected = !deleteMessages + ) + ) { + deleteMessages = false + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(state.removeMessagesText), + selected = deleteMessages, + ) + ) { + deleteMessages = true + } + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.remove)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(EditGroupViewModel.Commands.DismissRemoveDialog) + sendCommand(EditGroupViewModel.Commands.RemoveMembers(deleteMessages)) + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(EditGroupViewModel.Commands.DismissRemoveDialog) + } + ) + ) + ) +} + @Preview @Composable private fun EditGroupPreviewSheet() { @@ -571,7 +583,6 @@ private fun EditGroupPreviewSheet() { onBack = {}, onAddMemberClick = {}, onPromoteClick = {}, - onRemoveClick = { _, _ -> }, members = listOf(oneMember, twoMember, threeMember), groupName = "Test ", showAddMembers = true, @@ -595,9 +606,11 @@ private fun EditGroupPreviewSheet() { ), onToggleFooter = {}, onCloseFooter = {}, - selectedAccountIds = emptySet(), - showingResend ="Resending Invite", - onResendDismissed = {} + selectedMembers = emptySet(), + showingResend = "Resending Invite", + onResendDismissed = {}, + sendCommands = {}, + removeMembersData = EditGroupViewModel.RemoveMembersState() ) } } @@ -680,7 +693,6 @@ private fun EditGroupEditNamePreview( onBack = {}, onAddMemberClick = {}, onPromoteClick = {}, - onRemoveClick = { _, _ -> }, members = listOf(oneMember, twoMember, threeMember), groupName = "Test name that is very very long indeed because many words in it", showAddMembers = true, @@ -696,7 +708,7 @@ private fun EditGroupEditNamePreview( onSearchFocusChanged = {}, searchFocused = true, onSearchQueryClear = {}, - data = CollapsibleFooterState( + data = CollapsibleFooterState( visible = true, collapsed = false, footerActionTitle = GetString("3 Members Selected"), @@ -717,9 +729,11 @@ private fun EditGroupEditNamePreview( ), onToggleFooter = {}, onCloseFooter = {}, - selectedAccountIds = emptySet(), - showingResend ="Resending Invite", - onResendDismissed = {} + selectedMembers = emptySet(), + showingResend = "Resending Invite", + onResendDismissed = {}, + sendCommands = {}, + removeMembersData = EditGroupViewModel.RemoveMembersState() ) } } \ No newline at end of file From 5351084d0c35f7178e756a5defad21431f918f5e Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 07:21:23 +0800 Subject: [PATCH 22/50] cleanup, screen and viewmodel renamed to ManageGroupMembers --- .../settings/ConversationSettingsNavHost.kt | 13 ++-- ...odel.kt => ManageGroupMembersViewModel.kt} | 7 +- ...pScreen.kt => ManageGroupMembersScreen.kt} | 66 ++++++------------- 3 files changed, 30 insertions(+), 56 deletions(-) rename app/src/main/java/org/thoughtcrime/securesms/groups/{EditGroupViewModel.kt => ManageGroupMembersViewModel.kt} (98%) rename app/src/main/java/org/thoughtcrime/securesms/groups/compose/{EditGroupScreen.kt => ManageGroupMembersScreen.kt} (92%) 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 f022bb6b1c..b5d2e47c3f 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 @@ -26,12 +26,11 @@ import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.Disappear import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.* import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsScreen import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsViewModel -import org.thoughtcrime.securesms.groups.EditGroupViewModel +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.SelectContactsViewModel -import org.thoughtcrime.securesms.groups.compose.EditGroupScreen +import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen -import org.thoughtcrime.securesms.groups.compose.GroupMinimumVersionBanner import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen import org.thoughtcrime.securesms.media.MediaOverviewScreen import org.thoughtcrime.securesms.media.MediaOverviewViewModel @@ -182,11 +181,11 @@ fun ConversationSettingsNavHost( val data: RouteManageMembers = backStackEntry.toRoute() val viewModel = - hiltViewModel { factory -> + hiltViewModel { factory -> factory.create(data.groupAddress) } - EditGroupScreen( + ManageGroupMembersScreen( viewModel = viewModel, navigateToInviteContact = { navController.navigate( @@ -219,13 +218,13 @@ fun ConversationSettingsNavHost( RouteManageMembers(data.groupAddress) ) } - val editGroupViewModel: EditGroupViewModel = hiltViewModel(parentEntry) + val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry) InviteContactsScreen( viewModel = viewModel, onDoneClicked = dropUnlessResumed { //send invites from the manage group screen - editGroupViewModel.onContactSelected(viewModel.currentSelected) + manageGroupMembersViewModel.onContactSelected(viewModel.currentSelected) handleBack() }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt similarity index 98% rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt rename to app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt index 791e8952f8..c09887e70c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -32,7 +32,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY -import org.session.libsession.utilities.recipients.displayName import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData @@ -40,8 +39,8 @@ import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUtils -@HiltViewModel(assistedFactory = EditGroupViewModel.Factory::class) -class EditGroupViewModel @AssistedInject constructor( +@HiltViewModel(assistedFactory = ManageGroupMembersViewModel.Factory::class) +class ManageGroupMembersViewModel @AssistedInject constructor( @Assisted private val groupAddress: Address.Group, @param:ApplicationContext private val context: Context, storage: StorageProtocol, @@ -379,6 +378,6 @@ class EditGroupViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(groupAddress: Address.Group): EditGroupViewModel + fun create(groupAddress: Address.Group): ManageGroupMembersViewModel } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt similarity index 92% rename from app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt rename to app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt index 9c5b896028..3c1ae23827 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -51,8 +51,8 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.groups.EditGroupViewModel -import org.thoughtcrime.securesms.groups.EditGroupViewModel.CollapsibleFooterState +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.CollapsibleFooterState import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell @@ -85,27 +85,22 @@ import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement @Composable -fun EditGroupScreen( - viewModel: EditGroupViewModel, +fun ManageGroupMembersScreen( + viewModel: ManageGroupMembersViewModel, navigateToInviteContact: (Set) -> Unit, onBack: () -> Unit, ) { - EditGroup( + ManageMembers( onBack = onBack, onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, - onPromoteClick = viewModel::onPromoteContact, members = viewModel.nonAdminMembers.collectAsState().value, selectedMembers = viewModel.selectedMembers.collectAsState().value, - groupName = viewModel.groupName.collectAsState().value, showAddMembers = viewModel.showAddMembers.collectAsState().value, - onResendPromotionClick = viewModel::onResendPromotionClicked, showingError = viewModel.error.collectAsState().value, onErrorDismissed = viewModel::onDismissError, showingResend = viewModel.resendString.collectAsState().value, onResendDismissed = viewModel::onDismissResend, onMemberClicked = viewModel::onMemberItemClicked, - hideActionSheet = viewModel::hideActionBottomSheet, - clickedMember = viewModel.clickedMember.collectAsState().value, showLoading = viewModel.inProgress.collectAsState().value, searchQuery = viewModel.searchQuery.collectAsState().value, searchFocused = viewModel.searchFocused.collectAsState().value, @@ -123,11 +118,9 @@ fun EditGroupScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EditGroup( +fun ManageMembers( onBack: () -> Unit, onAddMemberClick: () -> Unit, - onResendPromotionClick: (accountId: AccountId) -> Unit, - onPromoteClick: (accountId: AccountId) -> Unit, onMemberClicked: (member: GroupMemberState) -> Unit, onSearchFocusChanged : (isFocused : Boolean) -> Unit, searchFocused : Boolean, @@ -137,9 +130,6 @@ fun EditGroup( onCloseFooter: () -> Unit, onSearchQueryChanged: (String) -> Unit, onSearchQueryClear: () -> Unit, - hideActionSheet: () -> Unit, - clickedMember: GroupMemberState?, - groupName: String, members: List, selectedMembers: Set = emptySet(), showAddMembers: Boolean, @@ -148,20 +138,16 @@ fun EditGroup( onResendDismissed: () -> Unit, showLoading: Boolean, onErrorDismissed: () -> Unit, - sendCommands: (command : EditGroupViewModel.Commands) -> Unit, - removeMembersData: EditGroupViewModel.RemoveMembersState + sendCommands: (command : ManageGroupMembersViewModel.Commands) -> Unit, + removeMembersData: ManageGroupMembersViewModel.RemoveMembersState ) { -// val (showingConfirmRemovingMember, setShowingConfirmRemovingMember) = remember { -// mutableStateOf(null) -// } - - val optionsList: List = listOf( - EditGroupViewModel.OptionsItem( + val optionsList: List = listOf( + ManageGroupMembersViewModel.OptionsItem( name = LocalResources.current.getString(R.string.membersInvite), icon = R.drawable.ic_user_round_plus, onClick = { onAddMemberClick() } ), - EditGroupViewModel.OptionsItem( + ManageGroupMembersViewModel.OptionsItem( name = LocalResources.current.getString(R.string.accountIdOrOnsInvite), icon = R.drawable.ic_user_round_search, onClick = { onAddMemberClick() } @@ -433,9 +419,9 @@ fun EditMemberItem( @Composable fun ShowRemoveMembersDialog( - state: EditGroupViewModel.RemoveMembersState, + state: ManageGroupMembersViewModel.RemoveMembersState, modifier: Modifier = Modifier, - sendCommand: (EditGroupViewModel.Commands) -> Unit + sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit ) { var deleteMessages by remember { mutableStateOf(false) } @@ -443,7 +429,7 @@ fun ShowRemoveMembersDialog( modifier = modifier, onDismissRequest = { // hide dialog - sendCommand(EditGroupViewModel.Commands.DismissRemoveDialog) + sendCommand(ManageGroupMembersViewModel.Commands.DismissRemoveDialog) }, title = annotatedStringResource(R.string.remove), text = annotatedStringResource(state.removeMemberBody), @@ -474,14 +460,14 @@ fun ShowRemoveMembersDialog( color = LocalColors.current.danger, dismissOnClick = false, onClick = { - sendCommand(EditGroupViewModel.Commands.DismissRemoveDialog) - sendCommand(EditGroupViewModel.Commands.RemoveMembers(deleteMessages)) + sendCommand(ManageGroupMembersViewModel.Commands.DismissRemoveDialog) + sendCommand(ManageGroupMembersViewModel.Commands.RemoveMembers(deleteMessages)) } ), DialogButtonData( text = GetString(stringResource(R.string.cancel)), onClick = { - sendCommand(EditGroupViewModel.Commands.DismissRemoveDialog) + sendCommand(ManageGroupMembersViewModel.Commands.DismissRemoveDialog) } ) ) @@ -579,19 +565,14 @@ private fun EditGroupPreviewSheet() { val (editingName, setEditingName) = remember { mutableStateOf(null) } - EditGroup( + ManageMembers( onBack = {}, onAddMemberClick = {}, - onPromoteClick = {}, members = listOf(oneMember, twoMember, threeMember), - groupName = "Test ", showAddMembers = true, - onResendPromotionClick = {}, showingError = "Error", onErrorDismissed = {}, onMemberClicked = {}, - hideActionSheet = {}, - clickedMember = oneMember, showLoading = false, searchQuery = "Test", onSearchQueryChanged = { }, @@ -610,7 +591,7 @@ private fun EditGroupPreviewSheet() { showingResend = "Resending Invite", onResendDismissed = {}, sendCommands = {}, - removeMembersData = EditGroupViewModel.RemoveMembersState() + removeMembersData = ManageGroupMembersViewModel.RemoveMembersState() ) } } @@ -689,19 +670,14 @@ private fun EditGroupEditNamePreview( statusLabel = "" ) - EditGroup( + ManageMembers( onBack = {}, onAddMemberClick = {}, - onPromoteClick = {}, members = listOf(oneMember, twoMember, threeMember), - groupName = "Test name that is very very long indeed because many words in it", showAddMembers = true, - onResendPromotionClick = {}, showingError = "Error", onErrorDismissed = {}, onMemberClicked = {}, - hideActionSheet = {}, - clickedMember = null, showLoading = false, searchQuery = "", onSearchQueryChanged = { }, @@ -733,7 +709,7 @@ private fun EditGroupEditNamePreview( showingResend = "Resending Invite", onResendDismissed = {}, sendCommands = {}, - removeMembersData = EditGroupViewModel.RemoveMembersState() + removeMembersData = ManageGroupMembersViewModel.RemoveMembersState() ) } } \ No newline at end of file From 5873753d95601e8ad097c21c323759216baaa22d Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 09:11:37 +0800 Subject: [PATCH 23/50] clear search state after removing --- .../groups/ManageGroupMembersViewModel.kt | 31 +++++++++++++------ .../compose/ManageGroupMembersScreen.kt | 2 +- gradle.properties | 27 ++++++---------- 3 files changed, 33 insertions(+), 27 deletions(-) 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 c09887e70c..c6edb8df34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -73,14 +73,14 @@ class ManageGroupMembersViewModel @AssistedInject constructor( private val mutableError = MutableStateFlow(null) val error: StateFlow get() = mutableError - private val _mutableResendString = MutableStateFlow(null) - val resendString: StateFlow = _mutableResendString + private val _mutableOngoingAction = MutableStateFlow(null) + val ongoingAction: StateFlow = _mutableOngoingAction // Output: val excludingAccountIDsFromContactSelection: Set get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId.hexString }.orEmpty() -// Output: Intermediate states + // Output: Intermediate states private val mutableSearchFocused = MutableStateFlow(false) val searchFocused: StateFlow get() = mutableSearchFocused @@ -230,11 +230,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( MemberInvite(id = member.accountId, shareHistory = shareHistory) } - onSearchFocusChanged(false) - onSearchQueryChanged("") - clearSelection() + removeSearchState() - _mutableResendString.value = context.resources.getQuantityString( + _mutableOngoingAction.value = context.resources.getQuantityString( R.plurals.resendingInvite, selectedMembers.value.size ) @@ -247,6 +245,12 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } + private fun removeSearchState(){ + onSearchFocusChanged(false) + onSearchQueryChanged("") + clearSelection() + } + fun onPromoteContact(memberSessionId: AccountId) { performGroupOperation(showLoading = false) { groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = false) @@ -254,10 +258,19 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } fun onRemoveContact(removeMessages: Boolean) { + _mutableOngoingAction.value = context.resources.getQuantityString( + R.plurals.removingMember, + selectedMembers.value.size, + selectedMembers.value.size + ) performGroupOperation(showLoading = false) { + val accountIdList = selectedMembers.value.map { it.accountId } + + removeSearchState() + groupManager.removeMembers( groupAccountId = groupId, - removedMembers = selectedMembers.value.map { it.accountId }, + removedMembers = accountIdList, removeMessages = removeMessages ) } @@ -329,7 +342,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( footerCollapsed.update { !it } } - fun onDismissResend() { _mutableResendString.value = null } + fun onDismissResend() { _mutableOngoingAction.value = null } private fun toggleRemoveDialog(visible : Boolean){ showRemoveMember.value = visible 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 3c1ae23827..e359fd00a8 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 @@ -98,7 +98,7 @@ fun ManageGroupMembersScreen( showAddMembers = viewModel.showAddMembers.collectAsState().value, showingError = viewModel.error.collectAsState().value, onErrorDismissed = viewModel::onDismissError, - showingResend = viewModel.resendString.collectAsState().value, + showingResend = viewModel.ongoingAction.collectAsState().value, onResendDismissed = viewModel::onDismissResend, onMemberClicked = viewModel::onMemberItemClicked, showLoading = viewModel.inProgress.collectAsState().value, diff --git a/gradle.properties b/gradle.properties index 05f1a1f6c1..7f19887515 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,23 +7,16 @@ # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # # When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true -#Mon Jun 26 09:56:43 AEST 2023 +#Thu Nov 06 07:31:16 PST 2025 android.enableJetifier=false - -org.gradle.jvmargs=-Xmx4096m - -# Limit the worker threads to avoid consuming too much memory -org.gradle.workers.max=4 -org.gradle.worker.heap.size=512m - -kotlin.daemon.jvmargs=-Xmx4096m - -android.useAndroidX=true -android.nonTransitiveRClass=false android.nonFinalResIds=false - -# Enable fast service loader to fix a crash in coroutine's test dispatcher set up -kotlinx.coroutines.fast.service.loader=true \ No newline at end of file +android.nonTransitiveRClass=false +android.useAndroidX=true +kotlin.daemon.jvmargs=-Xmx4096m +kotlinx.coroutines.fast.service.loader=true +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx1024M" +org.gradle.worker.heap.size=512m +org.gradle.workers.max=4 From 5aebd8d981cdd73061802504b43c30628eb11349 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 09:52:09 +0800 Subject: [PATCH 24/50] Fixed some button logic --- .../compose/ManageGroupMembersScreen.kt | 2 +- .../thoughtcrime/securesms/ui/Components.kt | 25 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) 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 e359fd00a8..fa59f66662 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 @@ -482,7 +482,7 @@ private fun EditGroupPreviewSheet() { // build tray items val trayItems = listOf( CollapsibleFooterItemData( - label = GetString("Resend"), + label = GetString("Reseaand"), buttonLabel = GetString("Resend"), isDanger = false, onClick = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 1e33a916c3..ba8b4713cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -75,6 +75,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -1000,7 +1001,7 @@ private fun CollapsibleFooterActions( val capDp = with(density) { capPx.toDp() } val single = items.size == 1 - val measuredMaxButtonWidthPx = remember(items, capPx) { mutableIntStateOf(1) } + var equalWidthPx by rememberSaveable(capPx) { mutableIntStateOf(-1) } // Only do the offscreen equal width computation when we have 2+ buttons. if (!single) { @@ -1013,7 +1014,7 @@ private fun CollapsibleFooterActions( val placeables = measurables.map { m -> m.measure( Constraints( - minWidth = 1, + minWidth = 0, maxWidth = capPx, minHeight = 0, maxHeight = parentConstraints.maxHeight @@ -1021,13 +1022,12 @@ private fun CollapsibleFooterActions( ) } val natural = placeables.maxOfOrNull { it.width } ?: 1 - measuredMaxButtonWidthPx.intValue = natural.coerceIn(1, capPx) + equalWidthPx = natural.coerceIn(0, capPx) + layout(0, 0) {} } } - val equalWidthDp = with(density) { measuredMaxButtonWidthPx.intValue.toDp() } - Column( modifier = Modifier .fillMaxWidth() @@ -1045,15 +1045,18 @@ private fun CollapsibleFooterActions( onClick = {}, qaTag = R.string.qa_collapsing_footer_action, endContent = { + val widthMod = + if (single) { + Modifier.wrapContentWidth().widthIn(max = capDp) + } else if (equalWidthPx >= 0) { + Modifier.width(with(density) { equalWidthPx.toDp() }) + } else { + Modifier.wrapContentWidth().widthIn(max = capDp) + } Box( modifier = Modifier .padding(start = LocalDimensions.current.smallSpacing) - .then( - if (single) Modifier - .wrapContentWidth() - .widthIn(max = capDp) - else Modifier.width(equalWidthDp) - ) + .then(widthMod) ) { SlimFillButtonRect( modifier = if (single) Modifier else Modifier.fillMaxWidth(), From 70df9fb429d3c39f5a6303eb167e073aff7d1835 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 09:58:08 +0800 Subject: [PATCH 25/50] Fixed resend invite quantity screen --- .../securesms/groups/ManageGroupMembersViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 c6edb8df34..fa2ad9c978 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -234,7 +234,8 @@ class ManageGroupMembersViewModel @AssistedInject constructor( _mutableOngoingAction.value = context.resources.getQuantityString( R.plurals.resendingInvite, - selectedMembers.value.size + invites.size, + invites.size ) // Reinvite with per-member shareHistory From df3f53e2d5dc6698600b90f8aae5b555e9e0a200 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 10:10:44 +0800 Subject: [PATCH 26/50] Empty state --- .../compose/ManageGroupMembersScreen.kt | 162 +++++++++++++----- 1 file changed, 115 insertions(+), 47 deletions(-) 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 fa59f66662..a5bb717741 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 @@ -22,6 +22,7 @@ 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 @@ -41,6 +42,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import com.squareup.phrase.Phrase @@ -122,8 +124,8 @@ fun ManageMembers( onBack: () -> Unit, onAddMemberClick: () -> Unit, onMemberClicked: (member: GroupMemberState) -> Unit, - onSearchFocusChanged : (isFocused : Boolean) -> Unit, - searchFocused : Boolean, + onSearchFocusChanged: (isFocused: Boolean) -> Unit, + searchFocused: Boolean, searchQuery: String, data: CollapsibleFooterState, onToggleFooter: () -> Unit, @@ -134,11 +136,11 @@ fun ManageMembers( selectedMembers: Set = emptySet(), showAddMembers: Boolean, showingError: String?, - showingResend:String?, + showingResend: String?, onResendDismissed: () -> Unit, showLoading: Boolean, onErrorDismissed: () -> Unit, - sendCommands: (command : ManageGroupMembersViewModel.Commands) -> Unit, + sendCommands: (command: ManageGroupMembersViewModel.Commands) -> Unit, removeMembersData: ManageGroupMembersViewModel.RemoveMembersState ) { val optionsList: List = listOf( @@ -182,9 +184,11 @@ fun ManageMembers( }, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddingValues -> - Column(modifier = Modifier - .padding(paddingValues) - .consumeWindowInsets(paddingValues)) { + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { AnimatedVisibility( // show only when add-members is enabled AND search is not focused @@ -225,50 +229,65 @@ fun ManageMembers( } } - if (!searchFocused) { - Text( - modifier = Modifier.padding( - start = LocalDimensions.current.mediumSpacing, - bottom = LocalDimensions.current.smallSpacing - ), - text = LocalResources.current.getString(R.string.membersNonAdmins), - style = LocalType.current.base, - color = LocalColors.current.textSecondary + if (members.isNotEmpty()) { + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = LocalResources.current.getString(R.string.membersNonAdmins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + + SearchBarWithCancel( + query = searchQuery, + onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = onSearchFocusChanged ) - } - SearchBarWithCancel( - query = searchQuery, - onValueChanged = onSearchQueryChanged, - onClear = onSearchQueryClear, - placeholder = if(searchFocused) "" else LocalResources.current.getString(R.string.search), - enabled = true, - isFocused = searchFocused, - modifier = Modifier.padding(horizontal =LocalDimensions.current.smallSpacing), - onFocusChanged = onSearchFocusChanged - ) + 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 - EditMemberItem( - modifier = Modifier.fillMaxWidth(), - member = member, - onClick = { onMemberClicked(member) }, - selected = member in selectedMembers - ) - } + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(members) { member -> + // Each member's view + EditMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { onMemberClicked(member) }, + selected = member in selectedMembers + ) + } - item { - Spacer( - modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) - ) + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } } + } else { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(R.string.NoNonAdminsInGroup), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) } } } @@ -596,7 +615,6 @@ private fun EditGroupPreviewSheet() { } } - @Preview @Composable private fun EditGroupEditNamePreview( @@ -712,4 +730,54 @@ private fun EditGroupEditNamePreview( removeMembersData = ManageGroupMembersViewModel.RemoveMembersState() ) } +} + +@Preview +@Composable +private fun EditGroupEmptyPreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ManageMembers( + onBack = {}, + onAddMemberClick = {}, + members = listOf(), + showAddMembers = true, + showingError = "Error", + onErrorDismissed = {}, + onMemberClicked = {}, + showLoading = false, + searchQuery = "", + onSearchQueryChanged = { }, + onSearchFocusChanged = {}, + searchFocused = true, + onSearchQueryClear = {}, + data = CollapsibleFooterState( + visible = false, + collapsed = true, + footerActionTitle = GetString("3 Members Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + ), + onToggleFooter = {}, + onCloseFooter = {}, + selectedMembers = emptySet(), + showingResend = "Resending Invite", + onResendDismissed = {}, + sendCommands = {}, + removeMembersData = ManageGroupMembersViewModel.RemoveMembersState() + ) + } } \ No newline at end of file From 59d87e9b9c5d85da668e8b361982bfcf6aefa998 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 10:41:30 +0800 Subject: [PATCH 27/50] Handle chevron search state --- .../groups/ManageGroupMembersViewModel.kt | 11 ++++--- .../compose/ManageGroupMembersScreen.kt | 30 ++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) 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 fa2ad9c978..30cd5f994e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -230,7 +230,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( MemberInvite(id = member.accountId, shareHistory = shareHistory) } - removeSearchState() + removeSearchState(true) _mutableOngoingAction.value = context.resources.getQuantityString( R.plurals.resendingInvite, @@ -246,10 +246,13 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - private fun removeSearchState(){ + fun removeSearchState(clearSelection : Boolean){ onSearchFocusChanged(false) onSearchQueryChanged("") - clearSelection() + + if(clearSelection){ + clearSelection() + } } fun onPromoteContact(memberSessionId: AccountId) { @@ -267,7 +270,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( performGroupOperation(showLoading = false) { val accountIdList = selectedMembers.value.map { it.accountId } - removeSearchState() + removeSearchState(true) groupManager.removeMembers( groupAccountId = groupId, 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 a5bb717741..1860674cbb 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 @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.groups.compose +import android.R.attr.data import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically @@ -113,11 +115,11 @@ fun ManageGroupMembersScreen( onSearchFocusChanged = viewModel::onSearchFocusChanged, onSearchQueryClear = { viewModel.onSearchQueryChanged("") }, sendCommands = viewModel::onCommand, - removeMembersData = viewModel.removeMembersState.collectAsState().value + removeMembersData = viewModel.removeMembersState.collectAsState().value, + removeSearchState = viewModel::removeSearchState ) } - @OptIn(ExperimentalMaterial3Api::class) @Composable fun ManageMembers( @@ -141,7 +143,8 @@ fun ManageMembers( showLoading: Boolean, onErrorDismissed: () -> Unit, sendCommands: (command: ManageGroupMembersViewModel.Commands) -> Unit, - removeMembersData: ManageGroupMembersViewModel.RemoveMembersState + removeMembersData: ManageGroupMembersViewModel.RemoveMembersState, + removeSearchState: (clearSelection : Boolean) -> Unit ) { val optionsList: List = listOf( ManageGroupMembersViewModel.OptionsItem( @@ -156,11 +159,21 @@ fun ManageMembers( ) ) + val handleBack: () -> Unit = { + when { + searchFocused -> removeSearchState(false) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + Scaffold( topBar = { BackAppBar( title = stringResource(id = R.string.manageMembers), - onBack = onBack, + onBack = handleBack, ) }, bottomBar = { @@ -610,7 +623,8 @@ private fun EditGroupPreviewSheet() { showingResend = "Resending Invite", onResendDismissed = {}, sendCommands = {}, - removeMembersData = ManageGroupMembersViewModel.RemoveMembersState() + removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), + removeSearchState = {} ) } } @@ -727,7 +741,8 @@ private fun EditGroupEditNamePreview( showingResend = "Resending Invite", onResendDismissed = {}, sendCommands = {}, - removeMembersData = ManageGroupMembersViewModel.RemoveMembersState() + removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), + removeSearchState = { } ) } } @@ -777,7 +792,8 @@ private fun EditGroupEmptyPreview( showingResend = "Resending Invite", onResendDismissed = {}, sendCommands = {}, - removeMembersData = ManageGroupMembersViewModel.RemoveMembersState() + removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), + removeSearchState = {} ) } } \ No newline at end of file From f8f4fb25ae21118c03b3035034c9eae9146590a9 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 11:06:05 +0800 Subject: [PATCH 28/50] Updated list sorting order --- .../groups/BaseGroupMembersViewModel.kt | 29 +++++++++++++++---- 1 file 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 286b673121..b2a985df7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -182,16 +182,35 @@ abstract class BaseGroupMembersViewModel( } } - // Refer to notion doc for the sorting logic + // Refer to manage members/admin PRD for the sorting logic private fun sortMembers(members: List, currentUserId: AccountId) = members.sortedWith( - compareBy{ it.accountId != currentUserId } // Current user comes first - .thenBy { !it.showAsAdmin } // Admins come first - .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) // Sort by name (case insensitive) - .thenBy { it.accountId } // Last resort: sort by account ID + compareBy { stateOrder(it.status) } + .thenBy { it.accountId != currentUserId } + .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + .thenBy { it.accountId } ) } +private fun stateOrder(status: GroupMember.Status?): Int = when (status) { + // 1. Invite failed + GroupMember.Status.INVITE_FAILED -> 0 + // 2. Invite not sent + GroupMember.Status.INVITE_NOT_SENT -> 1 + // 3. Sending invite + GroupMember.Status.INVITE_SENDING -> 2 + // 4. Invite sent + GroupMember.Status.INVITE_SENT -> 3 + // 5. Invite status unknown + GroupMember.Status.INVITE_UNKNOWN -> 4 + // 6. Pending removal + GroupMember.Status.REMOVED, + GroupMember.Status.REMOVED_UNKNOWN, + GroupMember.Status.REMOVED_INCLUDING_MESSAGES -> 5 + // 7. Member (everything else) + else -> 6 +} + data class GroupMemberState( val accountId: AccountId, val avatarUIData: AvatarUIData, From 1db465655c31619df8cb284de6cfa1885625327c Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 11:13:20 +0800 Subject: [PATCH 29/50] Cleanup --- .../securesms/groups/compose/ManageGroupMembersScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 1860674cbb..30c5d3bfd4 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 @@ -155,7 +155,9 @@ fun ManageMembers( ManageGroupMembersViewModel.OptionsItem( name = LocalResources.current.getString(R.string.accountIdOrOnsInvite), icon = R.drawable.ic_user_round_search, - onClick = { onAddMemberClick() } + onClick = { + // TODO: Navigate to invite via accountId or ONS screen + } ) ) From 8ed326e902ae07fe677f177fba5d70263a313780 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 11:31:35 +0800 Subject: [PATCH 30/50] Cleanups --- .../securesms/groups/GroupManagerV2Impl.kt | 109 ------------------ 1 file changed, 109 deletions(-) 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 1fa5c146d7..82623a4f4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -241,115 +241,6 @@ class GroupManagerV2Impl @Inject constructor( isReinvite = true ) -// override suspend fun inviteMembers( -// group: AccountId, -// newMembers: List, -// shareHistory: Boolean, -// isReinvite: Boolean -// ): Unit = scope.launchAndWait(group, "Invite members") { -// val adminKey = requireAdminAccess(group) -// val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) -// -// val batchRequests = mutableListOf() -// -// // Construct the new members in our config -// val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> -// // Construct the new members in the config -// for (newMember in newMembers) { -// val toSet = configs.groupMembers.get(newMember.hexString) -// ?.also { existing -> -// val status = configs.groupMembers.status(existing) -// if (status == GroupMember.Status.INVITE_FAILED || status == GroupMember.Status.INVITE_SENT) { -// existing.setSupplement(shareHistory) -// } -// } -// ?: configs.groupMembers.getOrConstruct(newMember.hexString).also { member -> -// val contact = configFactory.withUserConfigs { configs -> -// configs.contacts.get(newMember.hexString) -// } -// -// member.setName(contact?.name.orEmpty()) -// member.setProfilePic(contact?.profilePicture ?: UserPic.DEFAULT) -// member.setSupplement(shareHistory) -// } -// -// toSet.setInvited() -// configs.groupMembers.set(toSet) -// } -// -// if (shareHistory) { -// val memberKey = configs.groupKeys.supplementFor(newMembers.map { it.hexString }) -// batchRequests.add( -// SnodeAPI.buildAuthenticatedStoreBatchInfo( -// namespace = Namespace.GROUP_KEYS(), -// message = SnodeMessage( -// recipient = group.hexString, -// data = Base64.encodeBytes(memberKey), -// ttl = SnodeMessage.CONFIG_TTL, -// timestamp = clock.currentTimeMills(), -// ), -// auth = groupAuth, -// ) -// ) -// } -// -// configs.rekey() -// newMembers.map { configs.groupKeys.getSubAccountToken(it.hexString) } -// } -// -// // Call un-revocate API on new members, in case they have been removed before -// batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( -// groupAdminAuth = groupAuth, -// subAccountTokens = subAccountTokens -// ) -// -// // Call the API -// try { -// val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() -// val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) -// -// // Make sure every request is successful -// response.requireAllRequestsSuccessful("Failed to invite members") -// -// // Wait for the group configs to be pushed -// configFactory.waitUntilGroupConfigsPushed(group) -// } catch (e: Exception) { -// // Update every member's status to "invite failed" and return group name -// val groupName = configFactory.withMutableGroupConfigs(group) { configs -> -// for (newMember in newMembers) { -// configs.groupMembers.get(newMember.hexString)?.apply { -// setInviteFailed() -// configs.groupMembers.set(this) -// } -// } -// -// configs.groupInfo.getName().orEmpty() -// } -// -// Log.w(TAG, "Failed to invite members to group $group", e) -// -// throw GroupInviteException( -// isPromotion = false, -// inviteeAccountIds = newMembers.map { it.hexString }, -// groupName = groupName, -// underlying = e -// ) -// } finally { -// // Send a group update message to the group telling members someone has been invited -// if (!isReinvite) { -// sendGroupUpdateForAddingMembers(group, adminKey, newMembers) -// } -// } -// -// // Send the invitation message to the new members -// JobQueue.shared.add( -// InviteContactsJob( -// group.hexString, -// newMembers.map { it.hexString }.toTypedArray() -// ) -// ) -// } - private suspend fun inviteMembersInternal( group: AccountId, memberInvites: List, From 360682bb62a271b06092b09d47d88a204efbc2ea Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 13:35:39 +0800 Subject: [PATCH 31/50] Revert heap size --- gradle.properties | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/gradle.properties b/gradle.properties index 7f19887515..dd29b09942 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,16 +7,23 @@ # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # # When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. For more details, visit -# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -#Thu Nov 06 07:31:16 PST 2025 +#Mon Jun 26 09:56:43 AEST 2023 android.enableJetifier=false -android.nonFinalResIds=false -android.nonTransitiveRClass=false -android.useAndroidX=true + +org.gradle.jvmargs=-Xmx4096m + +# Limit the worker threads to avoid consuming too much memory +org.gradle.workers.max=4 +org.gradle.worker.heap.size=512m + kotlin.daemon.jvmargs=-Xmx4096m + +android.useAndroidX=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false + +# Enable fast service loader to fix a crash in coroutine's test dispatcher set up kotlinx.coroutines.fast.service.loader=true -org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx1024M" -org.gradle.worker.heap.size=512m -org.gradle.workers.max=4 From 676ad5539962ad111a5367935e7894a33297c56b Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 6 Nov 2025 13:36:57 +0800 Subject: [PATCH 32/50] Cleanups --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index dd29b09942..05f1a1f6c1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,4 +26,4 @@ android.nonTransitiveRClass=false android.nonFinalResIds=false # Enable fast service loader to fix a crash in coroutine's test dispatcher set up -kotlinx.coroutines.fast.service.loader=true +kotlinx.coroutines.fast.service.loader=true \ No newline at end of file From d315cb872bf006e1950c18eb7b6d128fb867f6fd Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 07:13:47 +0800 Subject: [PATCH 33/50] Updated icon dimens --- app/src/main/res/drawable/ic_add_admin_custom.xml | 7 ++++--- app/src/main/res/drawable/ic_user_round_search.xml | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/drawable/ic_add_admin_custom.xml b/app/src/main/res/drawable/ic_add_admin_custom.xml index 2c885d7aec..675fbac6e3 100644 --- a/app/src/main/res/drawable/ic_add_admin_custom.xml +++ b/app/src/main/res/drawable/ic_add_admin_custom.xml @@ -1,8 +1,9 @@ + android:viewportHeight="50" + android:autoMirrored="true"> diff --git a/app/src/main/res/drawable/ic_user_round_search.xml b/app/src/main/res/drawable/ic_user_round_search.xml index 51fb13d6b9..c192ef068a 100644 --- a/app/src/main/res/drawable/ic_user_round_search.xml +++ b/app/src/main/res/drawable/ic_user_round_search.xml @@ -1,4 +1,4 @@ - + From 398af1c02bacc5af9e794abee12dd4324a7d8d14 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 08:29:24 +0800 Subject: [PATCH 34/50] Updated nonAdminMember flow --- .../securesms/groups/BaseGroupMembersViewModel.kt | 7 +------ 1 file changed, 1 insertion(+), 6 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 b2a985df7b..d913044751 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -90,12 +90,7 @@ abstract class BaseGroupMembersViewModel( ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) // Output: List of only NON-ADMINS - @OptIn(FlowPreview::class) - val nonAdminMembers: StateFlow> = combine( - groupInfo.map { it?.second.orEmpty() }, - mutableSearchQuery.debounce(100L), - ::filterContacts - ) + val nonAdminMembers: StateFlow> = members .map { list -> list.filter { !it.showAsAdmin } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) From 65728c96e1fbd8b463d258825e8cfd312b5422cd Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 08:46:28 +0800 Subject: [PATCH 35/50] Fixed unsafe member config --- .../securesms/groups/ManageGroupMembersViewModel.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 30cd5f994e..c137e50de1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -222,12 +222,12 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } ) { // Look up current member configs once - val membersCfg = configFactory.withGroupConfigs(groupId) { it.groupMembers } - - // Build per-member invites with their own shareHistory flag - val invites = selectedMembers.value.distinct().map { member -> - val shareHistory = membersCfg?.getOrNull(member.accountId.hexString)?.supplement == true - MemberInvite(id = member.accountId, shareHistory = shareHistory) + val invites: List = configFactory.withGroupConfigs(groupId) { cfg -> + selectedMembers.value.map { member -> + val shareHistory = + cfg.groupMembers.getOrNull(member.accountId.hexString)?.supplement == true + MemberInvite(id = member.accountId, shareHistory = shareHistory) + } } removeSearchState(true) From 682b84cfc5f34e9999e682c1710e21bad235a187 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 09:42:51 +0800 Subject: [PATCH 36/50] test --- .../securesms/groups/GroupManagerV2Impl.kt | 190 +++++++++--------- 1 file changed, 97 insertions(+), 93 deletions(-) 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 82623a4f4c..d1eefda1a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -246,99 +246,103 @@ class GroupManagerV2Impl @Inject constructor( memberInvites: List, isReinvite: Boolean ): Unit = scope.launchAndWait(group, if (isReinvite) "Reinvite members" else "Invite members") { - val adminKey = requireAdminAccess(group) - val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) - - val batchRequests = mutableListOf() - - val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> - val shareHistoryHexes = mutableListOf() - - for ((id, shareHistory) in memberInvites) { - val hex = id.hexString - - val toSet = configs.groupMembers.get(hex) - ?.also { existing -> - val status = configs.groupMembers.status(existing) - if (status == GroupMember.Status.INVITE_FAILED || status == GroupMember.Status.INVITE_SENT) { - existing.setSupplement(shareHistory) - } - } - ?: configs.groupMembers.getOrConstruct(hex).also { member -> - val contact = configFactory.withUserConfigs { it.contacts.get(hex) } - member.setName(contact?.name.orEmpty()) - member.setProfilePic(contact?.profilePicture ?: UserPic.DEFAULT) - member.setSupplement(shareHistory) - } - - if (shareHistory) shareHistoryHexes += hex - - toSet.setInvited() - configs.groupMembers.set(toSet) - } - - if (shareHistoryHexes.isNotEmpty()) { - val memberKey = configs.groupKeys.supplementFor(shareHistoryHexes) - batchRequests.add( - SnodeAPI.buildAuthenticatedStoreBatchInfo( - namespace = Namespace.GROUP_KEYS(), - message = SnodeMessage( - recipient = group.hexString, - data = Base64.encodeBytes(memberKey), - ttl = SnodeMessage.CONFIG_TTL, - timestamp = clock.currentTimeMills(), - ), - auth = groupAuth, - ) - ) - } - - configs.rekey() - memberInvites.map { configs.groupKeys.getSubAccountToken(it.id.hexString) } - } - - // Call un-revocate API on new members, in case they have been removed before - batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( - groupAdminAuth = groupAuth, - subAccountTokens = subAccountTokens - ) - - try { - val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() - val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) - - // Make sure every request is successful - response.requireAllRequestsSuccessful("Failed to invite members") - - // Wait for the group configs to be pushed - configFactory.waitUntilGroupConfigsPushed(group) - } catch (e: Exception) { - // Update every member's status to "invite failed" and return group name - val groupName = configFactory.withMutableGroupConfigs(group) { configs -> - for ((id, _) in memberInvites) { - configs.groupMembers.get(id.hexString)?.apply { - setInviteFailed() - configs.groupMembers.set(this) - } - } - configs.groupInfo.getName().orEmpty() - } - - Log.w(TAG, "Failed to invite members to group $group", e) - - throw GroupInviteException( - isPromotion = false, - inviteeAccountIds = memberInvites.map { it.id.hexString }, - groupName = groupName, - underlying = e, - isReinvite = isReinvite - ) - } finally { - // Send a group update message to the group telling members someone has been invited - if (!isReinvite) { - sendGroupUpdateForAddingMembers(group, adminKey, memberInvites.map { it.id }) - } - } + try { + val adminKey = requireAdminAccess(group) + val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) + + val batchRequests = mutableListOf() + + val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> + val shareHistoryHexes = mutableListOf() + + for ((id, shareHistory) in memberInvites) { + val hex = id.hexString + + val toSet = configs.groupMembers.get(hex) + ?.also { existing -> + val status = configs.groupMembers.status(existing) + if (status == GroupMember.Status.INVITE_FAILED || status == GroupMember.Status.INVITE_SENT) { + existing.setSupplement(shareHistory) + } + } + ?: configs.groupMembers.getOrConstruct(hex).also { member -> + val contact = configFactory.withUserConfigs { it.contacts.get(hex) } + member.setName(contact?.name.orEmpty()) + member.setProfilePic(contact?.profilePicture ?: UserPic.DEFAULT) + member.setSupplement(shareHistory) + } + + if (shareHistory) shareHistoryHexes += hex + + toSet.setInvited() + configs.groupMembers.set(toSet) + } + + if (shareHistoryHexes.isNotEmpty()) { + val memberKey = configs.groupKeys.supplementFor(shareHistoryHexes) + batchRequests.add( + SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace = Namespace.GROUP_KEYS(), + message = SnodeMessage( + recipient = group.hexString, + data = Base64.encodeBytes(memberKey), + ttl = SnodeMessage.CONFIG_TTL, + timestamp = clock.currentTimeMills(), + ), + auth = groupAuth, + ) + ) + } + + configs.rekey() + memberInvites.map { configs.groupKeys.getSubAccountToken(it.id.hexString) } + } + + // Call un-revocate API on new members, in case they have been removed before + batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( + groupAdminAuth = groupAuth, + subAccountTokens = subAccountTokens + ) + + try { + val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() + val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) + + // Make sure every request is successful + response.requireAllRequestsSuccessful("Failed to invite members") + + // Wait for the group configs to be pushed + configFactory.waitUntilGroupConfigsPushed(group) + } catch (e: Exception) { + // Update every member's status to "invite failed" and return group name + val groupName = configFactory.withMutableGroupConfigs(group) { configs -> + for ((id, _) in memberInvites) { + configs.groupMembers.get(id.hexString)?.apply { + setInviteFailed() + configs.groupMembers.set(this) + } + } + configs.groupInfo.getName().orEmpty() + } + + Log.w(TAG, "Failed to invite members to group $group", e) + + throw GroupInviteException( + isPromotion = false, + inviteeAccountIds = memberInvites.map { it.id.hexString }, + groupName = groupName, + underlying = e, + isReinvite = isReinvite + ) + } finally { + // Send a group update message to the group telling members someone has been invited + if (!isReinvite) { + sendGroupUpdateForAddingMembers(group, adminKey, memberInvites.map { it.id }) + } + } + }catch (e : Exception){ + Log.w(TAG, "Failed to invite members to group $group", e) + } // Send the invitation message to the new members JobQueue.shared.add( From 850ff03b96542a1393f92e62312952237257baf1 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 09:48:55 +0800 Subject: [PATCH 37/50] Revert "test" This reverts commit 682b84cfc5f34e9999e682c1710e21bad235a187. --- .../securesms/groups/GroupManagerV2Impl.kt | 190 +++++++++--------- 1 file changed, 93 insertions(+), 97 deletions(-) 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 d1eefda1a4..82623a4f4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -246,103 +246,99 @@ class GroupManagerV2Impl @Inject constructor( memberInvites: List, isReinvite: Boolean ): Unit = scope.launchAndWait(group, if (isReinvite) "Reinvite members" else "Invite members") { - try { - val adminKey = requireAdminAccess(group) - val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) - - val batchRequests = mutableListOf() - - val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> - val shareHistoryHexes = mutableListOf() - - for ((id, shareHistory) in memberInvites) { - val hex = id.hexString - - val toSet = configs.groupMembers.get(hex) - ?.also { existing -> - val status = configs.groupMembers.status(existing) - if (status == GroupMember.Status.INVITE_FAILED || status == GroupMember.Status.INVITE_SENT) { - existing.setSupplement(shareHistory) - } - } - ?: configs.groupMembers.getOrConstruct(hex).also { member -> - val contact = configFactory.withUserConfigs { it.contacts.get(hex) } - member.setName(contact?.name.orEmpty()) - member.setProfilePic(contact?.profilePicture ?: UserPic.DEFAULT) - member.setSupplement(shareHistory) - } - - if (shareHistory) shareHistoryHexes += hex - - toSet.setInvited() - configs.groupMembers.set(toSet) - } - - if (shareHistoryHexes.isNotEmpty()) { - val memberKey = configs.groupKeys.supplementFor(shareHistoryHexes) - batchRequests.add( - SnodeAPI.buildAuthenticatedStoreBatchInfo( - namespace = Namespace.GROUP_KEYS(), - message = SnodeMessage( - recipient = group.hexString, - data = Base64.encodeBytes(memberKey), - ttl = SnodeMessage.CONFIG_TTL, - timestamp = clock.currentTimeMills(), - ), - auth = groupAuth, - ) - ) - } - - configs.rekey() - memberInvites.map { configs.groupKeys.getSubAccountToken(it.id.hexString) } - } - - // Call un-revocate API on new members, in case they have been removed before - batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( - groupAdminAuth = groupAuth, - subAccountTokens = subAccountTokens - ) - - try { - val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() - val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) - - // Make sure every request is successful - response.requireAllRequestsSuccessful("Failed to invite members") - - // Wait for the group configs to be pushed - configFactory.waitUntilGroupConfigsPushed(group) - } catch (e: Exception) { - // Update every member's status to "invite failed" and return group name - val groupName = configFactory.withMutableGroupConfigs(group) { configs -> - for ((id, _) in memberInvites) { - configs.groupMembers.get(id.hexString)?.apply { - setInviteFailed() - configs.groupMembers.set(this) - } - } - configs.groupInfo.getName().orEmpty() - } - - Log.w(TAG, "Failed to invite members to group $group", e) - - throw GroupInviteException( - isPromotion = false, - inviteeAccountIds = memberInvites.map { it.id.hexString }, - groupName = groupName, - underlying = e, - isReinvite = isReinvite - ) - } finally { - // Send a group update message to the group telling members someone has been invited - if (!isReinvite) { - sendGroupUpdateForAddingMembers(group, adminKey, memberInvites.map { it.id }) - } - } - }catch (e : Exception){ - Log.w(TAG, "Failed to invite members to group $group", e) - } + val adminKey = requireAdminAccess(group) + val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) + + val batchRequests = mutableListOf() + + val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> + val shareHistoryHexes = mutableListOf() + + for ((id, shareHistory) in memberInvites) { + val hex = id.hexString + + val toSet = configs.groupMembers.get(hex) + ?.also { existing -> + val status = configs.groupMembers.status(existing) + if (status == GroupMember.Status.INVITE_FAILED || status == GroupMember.Status.INVITE_SENT) { + existing.setSupplement(shareHistory) + } + } + ?: configs.groupMembers.getOrConstruct(hex).also { member -> + val contact = configFactory.withUserConfigs { it.contacts.get(hex) } + member.setName(contact?.name.orEmpty()) + member.setProfilePic(contact?.profilePicture ?: UserPic.DEFAULT) + member.setSupplement(shareHistory) + } + + if (shareHistory) shareHistoryHexes += hex + + toSet.setInvited() + configs.groupMembers.set(toSet) + } + + if (shareHistoryHexes.isNotEmpty()) { + val memberKey = configs.groupKeys.supplementFor(shareHistoryHexes) + batchRequests.add( + SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace = Namespace.GROUP_KEYS(), + message = SnodeMessage( + recipient = group.hexString, + data = Base64.encodeBytes(memberKey), + ttl = SnodeMessage.CONFIG_TTL, + timestamp = clock.currentTimeMills(), + ), + auth = groupAuth, + ) + ) + } + + configs.rekey() + memberInvites.map { configs.groupKeys.getSubAccountToken(it.id.hexString) } + } + + // Call un-revocate API on new members, in case they have been removed before + batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( + groupAdminAuth = groupAuth, + subAccountTokens = subAccountTokens + ) + + try { + val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() + val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) + + // Make sure every request is successful + response.requireAllRequestsSuccessful("Failed to invite members") + + // Wait for the group configs to be pushed + configFactory.waitUntilGroupConfigsPushed(group) + } catch (e: Exception) { + // Update every member's status to "invite failed" and return group name + val groupName = configFactory.withMutableGroupConfigs(group) { configs -> + for ((id, _) in memberInvites) { + configs.groupMembers.get(id.hexString)?.apply { + setInviteFailed() + configs.groupMembers.set(this) + } + } + configs.groupInfo.getName().orEmpty() + } + + Log.w(TAG, "Failed to invite members to group $group", e) + + throw GroupInviteException( + isPromotion = false, + inviteeAccountIds = memberInvites.map { it.id.hexString }, + groupName = groupName, + underlying = e, + isReinvite = isReinvite + ) + } finally { + // Send a group update message to the group telling members someone has been invited + if (!isReinvite) { + sendGroupUpdateForAddingMembers(group, adminKey, memberInvites.map { it.id }) + } + } // Send the invitation message to the new members JobQueue.shared.add( From 8a96d6a2699f777be4c509fc5df60d7e6b15d347 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 10:00:05 +0800 Subject: [PATCH 38/50] test --- .../main/java/org/session/libsession/messaging/jobs/JobQueue.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 38cda9a392..da16cf31da 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -200,6 +200,7 @@ class JobQueue : JobDelegate { val pendingJobs = mutableListOf() for ((id, job) in allPendingJobs) { if (job == null) { + Log.e("JobQueue", "Dropping incompatible job type=$typeKey id=$id (deserialize returned null)") // Job failed to deserialize, remove it from the DB handleJobFailedPermanently(id) } else { From e14871d61c527e9d2d9d6a304a93e444b5cc1010 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 10:12:30 +0800 Subject: [PATCH 39/50] Revert "test" This reverts commit 8a96d6a2699f777be4c509fc5df60d7e6b15d347. --- .../main/java/org/session/libsession/messaging/jobs/JobQueue.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index da16cf31da..38cda9a392 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -200,7 +200,6 @@ class JobQueue : JobDelegate { val pendingJobs = mutableListOf() for ((id, job) in allPendingJobs) { if (job == null) { - Log.e("JobQueue", "Dropping incompatible job type=$typeKey id=$id (deserialize returned null)") // Job failed to deserialize, remove it from the DB handleJobFailedPermanently(id) } else { From a44536303ceb5183b1007b3097b342d8d7a3acc3 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 10:51:56 +0800 Subject: [PATCH 40/50] Updated search with x and close button --- .../compose/ManageGroupMembersScreen.kt | 5 +- .../thoughtcrime/securesms/ui/Components.kt | 77 +++++-------------- 2 files changed, 22 insertions(+), 60 deletions(-) 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 30c5d3bfd4..ee4d6ce25b 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 @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.groups.compose -import android.R.attr.data import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -69,7 +68,7 @@ import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.ItemButton import org.thoughtcrime.securesms.ui.LoadingDialog import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.SearchBarWithCancel +import org.thoughtcrime.securesms.ui.SearchBarWithClose import org.thoughtcrime.securesms.ui.components.ActionSheet import org.thoughtcrime.securesms.ui.components.ActionSheetItemData import org.thoughtcrime.securesms.ui.components.BackAppBar @@ -257,7 +256,7 @@ fun ManageMembers( ) } - SearchBarWithCancel( + SearchBarWithClose( query = searchQuery, onValueChanged = onSearchQueryChanged, onClear = onSearchQueryClear, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 05acc941f1..3cdb0267d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -4,8 +4,6 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutSlowInEasing @@ -36,14 +34,12 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -711,7 +707,7 @@ fun SearchBar( modifier: Modifier = Modifier, placeholder: String? = null, enabled: Boolean = true, - backgroundColor: Color = LocalColors.current.background + backgroundColor: Color = LocalColors.current.background, ) { BasicTextField( singleLine = true, @@ -778,11 +774,11 @@ fun SearchBar( } /** - * Search with the cancel action + * Search with the close action for removing focus */ @Composable -fun SearchBarWithCancel( +fun SearchBarWithClose( query: String, onValueChanged: (String) -> Unit, onClear: () -> Unit, @@ -793,6 +789,7 @@ fun SearchBarWithCancel( enabled: Boolean = true, backgroundColor: Color = LocalColors.current.backgroundSecondary, ) { + val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current val focusRequester = remember { FocusRequester() } @@ -814,65 +811,27 @@ fun SearchBarWithCancel( horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ) { - // Search field - BasicTextField( - singleLine = true, - value = query, - onValueChange = onValueChanged, + SearchBar( + query = query, + onValueChanged = onValueChanged, + onClear = onClear, + placeholder = placeholder, enabled = enabled, - textStyle = LocalType.current.base.copy(color = LocalColors.current.text), - cursorBrush = SolidColor(LocalColors.current.text), + backgroundColor = backgroundColor, modifier = Modifier - .weight(1f) // leave room on the right for the cancel button - .heightIn(min = LocalDimensions.current.minSearchInputHeight) + .weight(1f) .background(backgroundColor, MaterialTheme.shapes.small) - .onFocusChanged { onFocusChanged(it.isFocused) }, - decorationBox = { innerTextField -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - Image( - painter = painterResource(id = R.drawable.ic_search), - contentDescription = null, - colorFilter = ColorFilter.tint(LocalColors.current.textSecondary), - modifier = Modifier - .padding( - horizontal = LocalDimensions.current.smallSpacing, - vertical = LocalDimensions.current.xxsSpacing - ) - .size(LocalDimensions.current.iconSmall) - ) - - Box( - modifier = Modifier - .weight(1f) - .padding(end = LocalDimensions.current.smallSpacing) - ) { - innerTextField() - if (query.isEmpty() && placeholder != null) { - Text( - modifier = Modifier.qaTag(R.string.qa_conversation_search_input), - text = placeholder, - color = LocalColors.current.textSecondary, - style = LocalType.current.xl - ) - } - } - } - } + .onFocusChanged { onFocusChanged(it.isFocused) } ) // Right-side Cancel (outside the search field) AnimatedVisibility(visible = isFocused) { Text( - text = LocalResources.current.getString(R.string.cancel), + text = LocalResources.current.getString(R.string.close), style = LocalType.current.base, color = LocalColors.current.text, modifier = Modifier .clickable { - onClear() focusManager.clearFocus(force = true) } .padding( @@ -1057,11 +1016,15 @@ private fun CollapsibleFooterActions( endContent = { val widthMod = if (single) { - Modifier.wrapContentWidth().widthIn(max = capDp) + Modifier + .wrapContentWidth() + .widthIn(max = capDp) } else if (equalWidthPx >= 0) { Modifier.width(with(density) { equalWidthPx.toDp() }) } else { - Modifier.wrapContentWidth().widthIn(max = capDp) + Modifier + .wrapContentWidth() + .widthIn(max = capDp) } Box( modifier = Modifier @@ -1618,7 +1581,7 @@ fun PreviewSearchWithCancel( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { - SearchBarWithCancel( + SearchBarWithClose( query = "Test Query", onValueChanged = { }, onClear = { }, From f0e66ae9e82310cd8755c7e1252be2aedf9d0775 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 11:09:27 +0800 Subject: [PATCH 41/50] Added hasMembers flag --- .../securesms/groups/BaseGroupMembersViewModel.kt | 5 +++++ .../securesms/groups/compose/ManageGroupMembersScreen.kt | 4 +++- 2 files changed, 8 insertions(+), 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 d913044751..9aa032af14 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,11 @@ abstract class BaseGroupMembersViewModel( .map { list -> list.filter { !it.showAsAdmin } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val hasNonAdminMembers: StateFlow = + groupInfo + .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin } } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + fun onSearchQueryChanged(query: String) { mutableSearchQuery.value = query } 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 ee4d6ce25b..c742adca8e 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 @@ -97,6 +97,7 @@ fun ManageGroupMembersScreen( onBack = onBack, onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, members = viewModel.nonAdminMembers.collectAsState().value, + hasMembers = viewModel.hasNonAdminMembers.collectAsState().value, selectedMembers = viewModel.selectedMembers.collectAsState().value, showAddMembers = viewModel.showAddMembers.collectAsState().value, showingError = viewModel.error.collectAsState().value, @@ -134,6 +135,7 @@ fun ManageMembers( onSearchQueryChanged: (String) -> Unit, onSearchQueryClear: () -> Unit, members: List, + hasMembers: Boolean = false, selectedMembers: Set = emptySet(), showAddMembers: Boolean, showingError: String?, @@ -243,7 +245,7 @@ fun ManageMembers( } } - if (members.isNotEmpty()) { + if (hasMembers) { if (!searchFocused) { Text( modifier = Modifier.padding( From db69eca5730f6614bc0bf344b4ff4e073955452c Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 11:11:22 +0800 Subject: [PATCH 42/50] Removed unused composables, flow and function --- .../groups/ManageGroupMembersViewModel.kt | 18 ---- .../compose/ManageGroupMembersScreen.kt | 91 ------------------- 2 files changed, 109 deletions(-) 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 c137e50de1..447ba0f549 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -65,10 +65,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( private val mutableInProgress = MutableStateFlow(false) val inProgress: StateFlow get() = mutableInProgress - // show action bottom sheet - private val _clickedMember: MutableStateFlow = MutableStateFlow(null) - val clickedMember: StateFlow get() = _clickedMember - // Output: errors private val mutableError = MutableStateFlow(null) val error: StateFlow get() = mutableError @@ -324,20 +320,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onMemberClicked(groupMember: GroupMemberState){ - // if the member is clickable (ie, not 'you') but is an admin with no possible actions, - // show a toast mentioning they can't be removed - if(!groupMember.canEdit && groupMember.showAsAdmin){ - mutableError.value = context.getString(R.string.adminCannotBeRemoved) - } else { // otherwise pass in the clicked member to display the action sheet - _clickedMember.value = groupMember - } - } - - fun hideActionBottomSheet(){ - _clickedMember.value = null - } - fun clearSelection(){ _mutableSelectedMembers.value = emptySet() } 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 c742adca8e..6b1fa87cb3 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 @@ -335,97 +335,6 @@ fun ManageMembers( } } -//todo : Delete after implementing collapsing bottom -@Composable -private fun ConfirmRemovingMemberDialog( - onConfirmed: (accountId: AccountId, removeMessages: Boolean) -> Unit, - onDismissRequest: () -> Unit, - member: GroupMemberState, - groupName: String, -) { - val context = LocalContext.current - val buttons = buildList { - this += DialogButtonData( - text = GetString(R.string.remove), - color = LocalColors.current.danger, - onClick = { onConfirmed(member.accountId, false) } - ) - - this += DialogButtonData( - text = GetString(R.string.cancel), - onClick = onDismissRequest, - ) - } - - AlertDialog( - onDismissRequest = onDismissRequest, - text = annotatedStringResource(Phrase.from(context, R.string.groupRemoveDescription) - .put(NAME_KEY, member.name) - .put(GROUP_NAME_KEY, groupName) - .format()), - title = AnnotatedString(stringResource(R.string.remove)), - buttons = buttons - ) -} - -// todo : delete after promote admin -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MemberActionSheet( - member: GroupMemberState, - onRemove: () -> Unit, - onPromote: () -> Unit, - onResendInvite: () -> Unit, - onResendPromotion: () -> Unit, - onDismissRequest: () -> Unit, -) { - val context = LocalContext.current - - val options = remember(member) { - buildList { - if (member.canRemove) { - this += ActionSheetItemData( - title = context.resources.getQuantityString(R.plurals.groupRemoveUserOnly, 1), - iconRes = R.drawable.ic_trash_2, - onClick = onRemove, - qaTag = R.string.AccessibilityId_removeContact - ) - } - - if (BuildConfig.BUILD_TYPE != "release" && member.canPromote) { - this += ActionSheetItemData( - title = context.getString(R.string.adminPromoteToAdmin), - iconRes = R.drawable.ic_user_filled_custom, - onClick = onPromote - ) - } - - if (member.canResendInvite) { - this += ActionSheetItemData( - title = "Resend invitation", - iconRes = R.drawable.ic_mail, - onClick = onResendInvite, - qaTag = R.string.AccessibilityId_resendInvite, - ) - } - - if (BuildConfig.BUILD_TYPE != "release" && member.canResendPromotion) { - this += ActionSheetItemData( - title = "Resend promotion", - iconRes = R.drawable.ic_mail, - onClick = onResendPromotion, - qaTag = R.string.AccessibilityId_resendInvite, - ) - } - } - } - - ActionSheet( - items = options, - onDismissRequest = onDismissRequest - ) -} - @Composable fun EditMemberItem( member: GroupMemberState, From 47256e1d67eca58b64e60842dba72ff4a9e03cb7 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 11:56:29 +0800 Subject: [PATCH 43/50] Code cleanup for commands, and unused code, renamed item --- .../groups/ManageGroupMembersViewModel.kt | 54 +++++++--- .../compose/ManageGroupMembersScreen.kt | 99 +++++-------------- 2 files changed, 67 insertions(+), 86 deletions(-) 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 447ba0f549..2aaa789bd4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -334,17 +334,31 @@ class ManageGroupMembersViewModel @AssistedInject constructor( showRemoveMember.value = visible } - fun onCommand(command : Commands){ - when (command){ - is Commands.ShowRemoveDialog -> { - toggleRemoveDialog(true) - } - is Commands.DismissRemoveDialog -> { - toggleRemoveDialog(false) - } - is Commands.RemoveMembers -> { - onRemoveContact(command.removeMessages) - } + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowRemoveDialog -> toggleRemoveDialog(true) + + is Commands.DismissRemoveDialog -> toggleRemoveDialog(false) + + is Commands.RemoveMembers -> onRemoveContact(command.removeMessages) + + is Commands.ClearSelection, + + is Commands.CloseFooter -> clearSelection() + + is Commands.ToggleFooter -> toggleFooter() + + is Commands.DismissError -> onDismissError() + + is Commands.DismissResend -> onDismissResend() + + is Commands.MemberClick -> onMemberItemClicked(command.member) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) } } @@ -372,7 +386,25 @@ class ManageGroupMembersViewModel @AssistedInject constructor( sealed interface Commands { data object ShowRemoveDialog : Commands data object DismissRemoveDialog : 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 RemoveMembers(val removeMessages: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands } @AssistedFactory 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 6b1fa87cb3..86f9695d5a 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 @@ -42,21 +42,17 @@ 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.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import com.squareup.phrase.Phrase -import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.utilities.Address -import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.CollapsibleFooterState -import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.* import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.CollapsibleFooterAction @@ -69,8 +65,6 @@ import org.thoughtcrime.securesms.ui.ItemButton import org.thoughtcrime.securesms.ui.LoadingDialog import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.SearchBarWithClose -import org.thoughtcrime.securesms.ui.components.ActionSheet -import org.thoughtcrime.securesms.ui.components.ActionSheetItemData import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -101,22 +95,13 @@ fun ManageGroupMembersScreen( selectedMembers = viewModel.selectedMembers.collectAsState().value, showAddMembers = viewModel.showAddMembers.collectAsState().value, showingError = viewModel.error.collectAsState().value, - onErrorDismissed = viewModel::onDismissError, showingResend = viewModel.ongoingAction.collectAsState().value, - onResendDismissed = viewModel::onDismissResend, - onMemberClicked = viewModel::onMemberItemClicked, showLoading = viewModel.inProgress.collectAsState().value, searchQuery = viewModel.searchQuery.collectAsState().value, searchFocused = viewModel.searchFocused.collectAsState().value, data = viewModel.collapsibleFooterState.collectAsState().value, - onToggleFooter = viewModel::toggleFooter, - onCloseFooter = viewModel::clearSelection, - onSearchQueryChanged = viewModel::onSearchQueryChanged, - onSearchFocusChanged = viewModel::onSearchFocusChanged, - onSearchQueryClear = { viewModel.onSearchQueryChanged("") }, - sendCommands = viewModel::onCommand, + sendCommand = viewModel::onCommand, removeMembersData = viewModel.removeMembersState.collectAsState().value, - removeSearchState = viewModel::removeSearchState ) } @@ -125,27 +110,18 @@ fun ManageGroupMembersScreen( fun ManageMembers( onBack: () -> Unit, onAddMemberClick: () -> Unit, - onMemberClicked: (member: GroupMemberState) -> Unit, - onSearchFocusChanged: (isFocused: Boolean) -> Unit, searchFocused: Boolean, searchQuery: String, data: CollapsibleFooterState, - onToggleFooter: () -> Unit, - onCloseFooter: () -> Unit, - onSearchQueryChanged: (String) -> Unit, - onSearchQueryClear: () -> Unit, members: List, hasMembers: Boolean = false, selectedMembers: Set = emptySet(), showAddMembers: Boolean, showingError: String?, showingResend: String?, - onResendDismissed: () -> Unit, showLoading: Boolean, - onErrorDismissed: () -> Unit, - sendCommands: (command: ManageGroupMembersViewModel.Commands) -> Unit, + sendCommand: (command: ManageGroupMembersViewModel.Commands) -> Unit, removeMembersData: ManageGroupMembersViewModel.RemoveMembersState, - removeSearchState: (clearSelection : Boolean) -> Unit ) { val optionsList: List = listOf( ManageGroupMembersViewModel.OptionsItem( @@ -164,7 +140,7 @@ fun ManageMembers( val handleBack: () -> Unit = { when { - searchFocused -> removeSearchState(false) + searchFocused -> sendCommand(RemoveSearchState(false)) else -> onBack() } } @@ -193,8 +169,8 @@ fun ManageMembers( visible = data.visible, items = data.footerActionItems ), - onCollapsedClicked = onToggleFooter, - onClosedClicked = onCloseFooter + onCollapsedClicked = {sendCommand(ToggleFooter)}, + onClosedClicked = { sendCommand(CloseFooter) } ) } }, @@ -260,13 +236,13 @@ fun ManageMembers( SearchBarWithClose( query = searchQuery, - onValueChanged = onSearchQueryChanged, - onClear = onSearchQueryClear, + 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 = onSearchFocusChanged + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } ) Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) @@ -279,10 +255,10 @@ fun ManageMembers( ) { items(members) { member -> // Each member's view - EditMemberItem( + ManageMemberItem( modifier = Modifier.fillMaxWidth(), member = member, - onClick = { onMemberClicked(member) }, + onClick = { sendCommand(MemberClick(member)) }, selected = member in selectedMembers ) } @@ -311,7 +287,7 @@ fun ManageMembers( if(removeMembersData.visible){ ShowRemoveMembersDialog( state = removeMembersData, - sendCommand = sendCommands + sendCommand = sendCommand ) } @@ -324,19 +300,19 @@ fun ManageMembers( LaunchedEffect(showingError) { if (showingError != null) { Toast.makeText(context, showingError, Toast.LENGTH_SHORT).show() - onErrorDismissed() + sendCommand(DismissError) } } LaunchedEffect(showingResend) { if (showingResend != null) { Toast.makeText(context, showingResend, Toast.LENGTH_SHORT).show() - onResendDismissed() + sendCommand(DismissResend) } } } @Composable -fun EditMemberItem( +fun ManageMemberItem( member: GroupMemberState, onClick: (address: Address) -> Unit, modifier: Modifier = Modifier, @@ -373,7 +349,7 @@ fun ShowRemoveMembersDialog( modifier = modifier, onDismissRequest = { // hide dialog - sendCommand(ManageGroupMembersViewModel.Commands.DismissRemoveDialog) + sendCommand(DismissRemoveDialog) }, title = annotatedStringResource(R.string.remove), text = annotatedStringResource(state.removeMemberBody), @@ -404,14 +380,14 @@ fun ShowRemoveMembersDialog( color = LocalColors.current.danger, dismissOnClick = false, onClick = { - sendCommand(ManageGroupMembersViewModel.Commands.DismissRemoveDialog) - sendCommand(ManageGroupMembersViewModel.Commands.RemoveMembers(deleteMessages)) + sendCommand(DismissRemoveDialog) + sendCommand(RemoveMembers(deleteMessages)) } ), DialogButtonData( text = GetString(stringResource(R.string.cancel)), onClick = { - sendCommand(ManageGroupMembersViewModel.Commands.DismissRemoveDialog) + sendCommand(DismissRemoveDialog) } ) ) @@ -507,7 +483,7 @@ private fun EditGroupPreviewSheet() { statusLabel = "" ) - val (editingName, setEditingName) = remember { mutableStateOf(null) } + val (_, _) = remember { mutableStateOf(null) } ManageMembers( onBack = {}, @@ -515,28 +491,19 @@ private fun EditGroupPreviewSheet() { members = listOf(oneMember, twoMember, threeMember), showAddMembers = true, showingError = "Error", - onErrorDismissed = {}, - onMemberClicked = {}, showLoading = false, searchQuery = "Test", - onSearchQueryChanged = { }, - onSearchFocusChanged = { }, searchFocused = false, - onSearchQueryClear = {}, data = CollapsibleFooterState( visible = true, collapsed = false, footerActionTitle = title, footerActionItems = trayItems ), - onToggleFooter = {}, - onCloseFooter = {}, selectedMembers = emptySet(), showingResend = "Resending Invite", - onResendDismissed = {}, - sendCommands = {}, + sendCommand = {}, removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), - removeSearchState = {} ) } } @@ -620,14 +587,9 @@ private fun EditGroupEditNamePreview( members = listOf(oneMember, twoMember, threeMember), showAddMembers = true, showingError = "Error", - onErrorDismissed = {}, - onMemberClicked = {}, showLoading = false, searchQuery = "", - onSearchQueryChanged = { }, - onSearchFocusChanged = {}, searchFocused = true, - onSearchQueryClear = {}, data = CollapsibleFooterState( visible = true, collapsed = false, @@ -647,14 +609,10 @@ private fun EditGroupEditNamePreview( ) ) ), - onToggleFooter = {}, - onCloseFooter = {}, selectedMembers = emptySet(), showingResend = "Resending Invite", - onResendDismissed = {}, - sendCommands = {}, + sendCommand = {}, removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), - removeSearchState = { } ) } } @@ -671,14 +629,9 @@ private fun EditGroupEmptyPreview( members = listOf(), showAddMembers = true, showingError = "Error", - onErrorDismissed = {}, - onMemberClicked = {}, showLoading = false, searchQuery = "", - onSearchQueryChanged = { }, - onSearchFocusChanged = {}, searchFocused = true, - onSearchQueryClear = {}, data = CollapsibleFooterState( visible = false, collapsed = true, @@ -698,14 +651,10 @@ private fun EditGroupEmptyPreview( ) ) ), - onToggleFooter = {}, - onCloseFooter = {}, selectedMembers = emptySet(), showingResend = "Resending Invite", - onResendDismissed = {}, - sendCommands = {}, + sendCommand = {}, removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), - removeSearchState = {} ) } } \ No newline at end of file From 8bdb2d968c28bf9f99fcc1686643ccbd62c4161f Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 7 Nov 2025 15:05:11 +0800 Subject: [PATCH 44/50] Moved options to state, initial uistate --- .../settings/ConversationSettingsNavHost.kt | 2 +- .../groups/ManageGroupMembersViewModel.kt | 42 ++++++++++++++++++- .../compose/ManageGroupMembersScreen.kt | 28 +++++-------- 3 files changed, 53 insertions(+), 19 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 b5d2e47c3f..b07feda5bd 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 @@ -182,7 +182,7 @@ fun ConversationSettingsNavHost( val viewModel = hiltViewModel { factory -> - factory.create(data.groupAddress) + factory.create(data.groupAddress, navigator) } ManageGroupMembersScreen( 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 2aaa789bd4..f2c9408db3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -33,15 +33,18 @@ import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_K import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY import org.session.libsignal.utilities.AccountId +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 @HiltViewModel(assistedFactory = ManageGroupMembersViewModel.Factory::class) class ManageGroupMembersViewModel @AssistedInject constructor( @Assisted private val groupAddress: Address.Group, + @Assisted private val navigator: UINavigator, @param:ApplicationContext private val context: Context, storage: StorageProtocol, private val configFactory: ConfigFactoryProtocol, @@ -85,6 +88,26 @@ class ManageGroupMembersViewModel @AssistedInject constructor( private val footerCollapsed = MutableStateFlow(false) + private val optionsList: List by lazy { + listOf( + OptionsItem( + name = context.getString(R.string.membersInvite), + icon = R.drawable.ic_user_round_plus, + onClick = ::navigateInviteContacts + ), + OptionsItem( + name = context.getString(R.string.accountIdOrOnsInvite), + icon = R.drawable.ic_user_round_search, + onClick = { + // TODO: Add navigation + } + ) + ) + } + + private val _uiState = MutableStateFlow(UiState(options = optionsList)) + val uiState: StateFlow = _uiState + val collapsibleFooterState: StateFlow = combine(_mutableSelectedMembers, footerCollapsed) { selected, isCollapsed -> val count = selected.size @@ -185,6 +208,17 @@ class ManageGroupMembersViewModel @AssistedInject constructor( mutableSearchFocused.value = isFocused } + private fun navigateInviteContacts() { + viewModelScope.launch { + navigator.navigate( + ConversationSettingsDestination.RouteInviteToGroup( + groupAddress, + excludingAccountIDsFromContactSelection.toList() + ) + ) + } + } + fun onContactSelected(contacts: Set
) { performGroupOperation( showLoading = false, @@ -362,6 +396,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } + data class UiState( + val options : List = emptyList() + ) data class CollapsibleFooterState( val visible: Boolean = false, val collapsed: Boolean = false, @@ -409,6 +446,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(groupAddress: Address.Group): ManageGroupMembersViewModel + fun create( + groupAddress: Address.Group, + navigator: UINavigator + ): ManageGroupMembersViewModel } } 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 86f9695d5a..71aa62e747 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 @@ -89,6 +89,7 @@ fun ManageGroupMembersScreen( ) { ManageMembers( onBack = onBack, + uiState = viewModel.uiState.collectAsState().value, onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, members = viewModel.nonAdminMembers.collectAsState().value, hasMembers = viewModel.hasNonAdminMembers.collectAsState().value, @@ -109,6 +110,7 @@ fun ManageGroupMembersScreen( @Composable fun ManageMembers( onBack: () -> Unit, + uiState: ManageGroupMembersViewModel.UiState, onAddMemberClick: () -> Unit, searchFocused: Boolean, searchQuery: String, @@ -123,20 +125,6 @@ fun ManageMembers( sendCommand: (command: ManageGroupMembersViewModel.Commands) -> Unit, removeMembersData: ManageGroupMembersViewModel.RemoveMembersState, ) { - val optionsList: List = listOf( - ManageGroupMembersViewModel.OptionsItem( - name = LocalResources.current.getString(R.string.membersInvite), - icon = R.drawable.ic_user_round_plus, - onClick = { onAddMemberClick() } - ), - ManageGroupMembersViewModel.OptionsItem( - name = LocalResources.current.getString(R.string.accountIdOrOnsInvite), - icon = R.drawable.ic_user_round_search, - onClick = { - // TODO: Navigate to invite via accountId or ONS screen - } - ) - ) val handleBack: () -> Unit = { when { @@ -202,20 +190,20 @@ fun ManageMembers( .padding(LocalDimensions.current.smallSpacing), ) { Column { - optionsList.forEachIndexed { index, option -> + uiState.options.forEachIndexed { index, option -> ItemButton( modifier = Modifier.qaTag(option.qaTag), text = annotatedStringResource(option.name), iconRes = option.icon, shape = when (index) { 0 -> getCellTopShape() - optionsList.lastIndex -> getCellBottomShape() + uiState.options.lastIndex -> getCellBottomShape() else -> RectangleShape }, onClick = option.onClick, ) - if (index != optionsList.lastIndex) Divider() + if (index != uiState.options.lastIndex) Divider() } } } @@ -504,6 +492,8 @@ private fun EditGroupPreviewSheet() { showingResend = "Resending Invite", sendCommand = {}, removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), + uiState = ManageGroupMembersViewModel.UiState(options = emptyList()), + hasMembers = true, ) } } @@ -613,6 +603,8 @@ private fun EditGroupEditNamePreview( showingResend = "Resending Invite", sendCommand = {}, removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), + uiState = ManageGroupMembersViewModel.UiState(options = emptyList()), + hasMembers = true, ) } } @@ -655,6 +647,8 @@ private fun EditGroupEmptyPreview( showingResend = "Resending Invite", sendCommand = {}, removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), + uiState = ManageGroupMembersViewModel.UiState(options = emptyList()), + hasMembers = true, ) } } \ No newline at end of file From f90d1db0a6391f2a36c1a379496ed4a26ca3e81f Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 10 Nov 2025 09:53:32 +0800 Subject: [PATCH 45/50] Added flows to UIState --- .../settings/ConversationSettingsNavHost.kt | 8 - .../groups/ManageGroupMembersViewModel.kt | 182 ++++++++++-------- .../compose/ManageGroupMembersScreen.kt | 50 ++--- 3 files changed, 112 insertions(+), 128 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 b07feda5bd..4dcc5837e4 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 @@ -187,14 +187,6 @@ fun ConversationSettingsNavHost( ManageGroupMembersScreen( viewModel = viewModel, - navigateToInviteContact = { - navController.navigate( - RouteInviteToGroup( - groupAddress = data.groupAddress, - excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection.toList() - ) - ) - }, 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 f2c9408db3..de329ac4e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -17,7 +17,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -64,25 +66,10 @@ class ManageGroupMembersViewModel @AssistedInject constructor( .map { it?.first?.isUserAdmin == true } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - // Output: Intermediate states - private val mutableInProgress = MutableStateFlow(false) - val inProgress: StateFlow get() = mutableInProgress - - // Output: errors - private val mutableError = MutableStateFlow(null) - val error: StateFlow get() = mutableError - - private val _mutableOngoingAction = MutableStateFlow(null) - val ongoingAction: StateFlow = _mutableOngoingAction - // Output: val excludingAccountIDsFromContactSelection: Set get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId.hexString }.orEmpty() - // Output: Intermediate states - private val mutableSearchFocused = MutableStateFlow(false) - val searchFocused: StateFlow get() = mutableSearchFocused - private val _mutableSelectedMembers = MutableStateFlow(emptySet()) val selectedMembers: StateFlow> = _mutableSelectedMembers @@ -147,56 +134,19 @@ class ManageGroupMembersViewModel @AssistedInject constructor( .distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState()) - private val showRemoveMember = MutableStateFlow(false) - val removeMembersState: StateFlow = + private val showRemoveMembersDialog = MutableStateFlow(false) + + init { combine( - showRemoveMember, + showRemoveMembersDialog, selectedMembers, groupName ) { showRemove, selected, group -> - val count = selected.size - val firstMember = selected.firstOrNull() - - val body = - when (count) { - 1 -> { - Phrase.from(context, R.string.groupRemoveDescription) - .put(NAME_KEY, firstMember?.name) - .put(GROUP_NAME_KEY, group) - .format() - } - - 2 -> { - val secondMember = selected.elementAtOrNull(1)?.name - Phrase.from(context, R.string.groupRemoveDescriptionTwo) - .put(NAME_KEY, firstMember?.name) - .put(OTHER_NAME_KEY, secondMember) - .put(GROUP_NAME_KEY, group) - .format() - } - - 0 -> "" - else -> { - Phrase.from(context, R.string.groupRemoveDescriptionMultiple) - .put(NAME_KEY, firstMember?.name) - .put(COUNT_KEY, count - 1) - .put(GROUP_NAME_KEY, group) - .format() - } - } - val removeMemberOnly = - context.resources.getQuantityString(R.plurals.removeMember, count, count) - val removeMessages = - context.resources.getQuantityString(R.plurals.removeMemberMessages, count, count) - - RemoveMembersState( - visible = showRemove, - removeMemberBody = body, - removeMemberText = removeMemberOnly, - removeMessagesText = removeMessages - ) - }.stateIn(viewModelScope, SharingStarted.Eagerly, RemoveMembersState()) - + buildRemoveMembersDialogState(showRemove, selected, group) + }.onEach { state -> + _uiState.update { it.copy(removeMembersDialog = state) } + }.launchIn(viewModelScope) + } fun onMemberItemClicked(member: GroupMemberState) { val newSet = _mutableSelectedMembers.value.toHashSet() if (!newSet.remove(member)) { @@ -205,7 +155,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( _mutableSelectedMembers.value = newSet } fun onSearchFocusChanged(isFocused :Boolean){ - mutableSearchFocused.value = isFocused + _uiState.update { it.copy(isSearchFocused = isFocused) } } private fun navigateInviteContacts() { @@ -262,11 +212,13 @@ class ManageGroupMembersViewModel @AssistedInject constructor( removeSearchState(true) - _mutableOngoingAction.value = context.resources.getQuantityString( - R.plurals.resendingInvite, - invites.size, - invites.size - ) + _uiState.update { it -> + it.copy(error = context.resources.getQuantityString( + R.plurals.resendingInvite, + invites.size, + invites.size + )) + } // Reinvite with per-member shareHistory groupManager.reinviteMembers( @@ -292,11 +244,14 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } fun onRemoveContact(removeMessages: Boolean) { - _mutableOngoingAction.value = context.resources.getQuantityString( - R.plurals.removingMember, - selectedMembers.value.size, - selectedMembers.value.size - ) + _uiState.update { it -> + it.copy(ongoingAction =context.resources.getQuantityString( + R.plurals.removingMember, + selectedMembers.value.size, + selectedMembers.value.size + )) + } + performGroupOperation(showLoading = false) { val accountIdList = selectedMembers.value.map { it.accountId } @@ -317,7 +272,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } fun onDismissError() { - mutableError.value = null + _uiState.update { it.copy(error = null) } } /** @@ -328,10 +283,11 @@ class ManageGroupMembersViewModel @AssistedInject constructor( private fun performGroupOperation( showLoading: Boolean = true, errorMessage: ((Throwable) -> String?)? = null, - operation: suspend () -> Unit) { + operation: suspend () -> Unit + ) { viewModelScope.launch { if (showLoading) { - mutableInProgress.value = true + _uiState.update { it.copy(inProgress = true) } } // We need to use GlobalScope here because we don't want @@ -344,11 +300,15 @@ class ManageGroupMembersViewModel @AssistedInject constructor( try { task.await() } catch (e: Exception) { - mutableError.value = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) + _uiState.update { + it.copy( + error = errorMessage?.invoke(e) + ?: context.getString(R.string.errorUnknown) + ) + } } finally { if (showLoading) { - mutableInProgress.value = false + _uiState.update { it.copy(inProgress = false) } } } } @@ -362,10 +322,12 @@ class ManageGroupMembersViewModel @AssistedInject constructor( footerCollapsed.update { !it } } - fun onDismissResend() { _mutableOngoingAction.value = null } + fun onDismissResend() { + _uiState.update { it.copy(ongoingAction = null) } + } private fun toggleRemoveDialog(visible : Boolean){ - showRemoveMember.value = visible + showRemoveMembersDialog.value = visible } fun onCommand(command: Commands) { @@ -396,9 +358,65 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } + private fun buildRemoveMembersDialogState( + visible: Boolean, + selected: Set, + group: String + ): RemoveMembersDialogState { + val count = selected.size + val firstMember = selected.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> Phrase.from(context, R.string.groupRemoveDescription) + .put(NAME_KEY, firstMember?.name) + .put(GROUP_NAME_KEY, group) + .format() + + 2 -> { + val secondMember = selected.elementAtOrNull(1)?.name + Phrase.from(context, R.string.groupRemoveDescriptionTwo) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .put(GROUP_NAME_KEY, group) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.groupRemoveDescriptionMultiple) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .put(GROUP_NAME_KEY, group) + .format() + } + + val removeMemberOnly = + context.resources.getQuantityString(R.plurals.removeMember, count, count) + val removeMessages = + context.resources.getQuantityString(R.plurals.removeMemberMessages, count, count) + + return RemoveMembersDialogState( + visible = visible, + removeMemberBody = body, + removeMemberText = removeMemberOnly, + removeMessagesText = removeMessages + ) + } + data class UiState( - val options : List = emptyList() + 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, + + // Remove member dialog + val removeMembersDialog: RemoveMembersDialogState = RemoveMembersDialogState(), ) + data class CollapsibleFooterState( val visible: Boolean = false, val collapsed: Boolean = false, @@ -406,7 +424,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( val footerActionItems : List = emptyList() ) - data class RemoveMembersState( + data class RemoveMembersDialogState( val visible : Boolean = false, val removeMemberBody : CharSequence = "", val removeMemberText : String = "", 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 71aa62e747..53b3161213 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 @@ -84,25 +84,18 @@ import org.thoughtcrime.securesms.util.AvatarUIElement @Composable fun ManageGroupMembersScreen( viewModel: ManageGroupMembersViewModel, - navigateToInviteContact: (Set) -> Unit, onBack: () -> Unit, ) { ManageMembers( onBack = onBack, uiState = viewModel.uiState.collectAsState().value, - onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, members = viewModel.nonAdminMembers.collectAsState().value, hasMembers = viewModel.hasNonAdminMembers.collectAsState().value, selectedMembers = viewModel.selectedMembers.collectAsState().value, showAddMembers = viewModel.showAddMembers.collectAsState().value, - showingError = viewModel.error.collectAsState().value, - showingResend = viewModel.ongoingAction.collectAsState().value, - showLoading = viewModel.inProgress.collectAsState().value, searchQuery = viewModel.searchQuery.collectAsState().value, - searchFocused = viewModel.searchFocused.collectAsState().value, data = viewModel.collapsibleFooterState.collectAsState().value, sendCommand = viewModel::onCommand, - removeMembersData = viewModel.removeMembersState.collectAsState().value, ) } @@ -111,21 +104,20 @@ fun ManageGroupMembersScreen( fun ManageMembers( onBack: () -> Unit, uiState: ManageGroupMembersViewModel.UiState, - onAddMemberClick: () -> Unit, - searchFocused: Boolean, searchQuery: String, data: CollapsibleFooterState, members: List, hasMembers: Boolean = false, selectedMembers: Set = emptySet(), showAddMembers: Boolean, - showingError: String?, - showingResend: String?, - showLoading: Boolean, sendCommand: (command: ManageGroupMembersViewModel.Commands) -> Unit, - removeMembersData: ManageGroupMembersViewModel.RemoveMembersState, +// removeMembersData: ManageGroupMembersViewModel.RemoveMembersDialogState, ) { + val searchFocused = uiState.isSearchFocused + val showingError = uiState.error + val showingOngoingAction = uiState.ongoingAction + val handleBack: () -> Unit = { when { searchFocused -> sendCommand(RemoveSearchState(false)) @@ -272,14 +264,14 @@ fun ManageMembers( } } - if(removeMembersData.visible){ + if(uiState.removeMembersDialog.visible){ ShowRemoveMembersDialog( - state = removeMembersData, + state = uiState.removeMembersDialog, sendCommand = sendCommand ) } - if (showLoading) { + if (uiState.inProgress) { LoadingDialog() } @@ -291,9 +283,9 @@ fun ManageMembers( sendCommand(DismissError) } } - LaunchedEffect(showingResend) { - if (showingResend != null) { - Toast.makeText(context, showingResend, Toast.LENGTH_SHORT).show() + LaunchedEffect(showingOngoingAction) { + if (showingOngoingAction != null) { + Toast.makeText(context, showingOngoingAction, Toast.LENGTH_SHORT).show() sendCommand(DismissResend) } } @@ -327,7 +319,7 @@ fun ManageMemberItem( @Composable fun ShowRemoveMembersDialog( - state: ManageGroupMembersViewModel.RemoveMembersState, + state: ManageGroupMembersViewModel.RemoveMembersDialogState, modifier: Modifier = Modifier, sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit ) { @@ -475,13 +467,9 @@ private fun EditGroupPreviewSheet() { ManageMembers( onBack = {}, - onAddMemberClick = {}, members = listOf(oneMember, twoMember, threeMember), showAddMembers = true, - showingError = "Error", - showLoading = false, searchQuery = "Test", - searchFocused = false, data = CollapsibleFooterState( visible = true, collapsed = false, @@ -489,9 +477,7 @@ private fun EditGroupPreviewSheet() { footerActionItems = trayItems ), selectedMembers = emptySet(), - showingResend = "Resending Invite", sendCommand = {}, - removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), uiState = ManageGroupMembersViewModel.UiState(options = emptyList()), hasMembers = true, ) @@ -573,13 +559,9 @@ private fun EditGroupEditNamePreview( ManageMembers( onBack = {}, - onAddMemberClick = {}, members = listOf(oneMember, twoMember, threeMember), showAddMembers = true, - showingError = "Error", - showLoading = false, searchQuery = "", - searchFocused = true, data = CollapsibleFooterState( visible = true, collapsed = false, @@ -600,9 +582,7 @@ private fun EditGroupEditNamePreview( ) ), selectedMembers = emptySet(), - showingResend = "Resending Invite", sendCommand = {}, - removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), uiState = ManageGroupMembersViewModel.UiState(options = emptyList()), hasMembers = true, ) @@ -617,13 +597,9 @@ private fun EditGroupEmptyPreview( PreviewTheme(colors) { ManageMembers( onBack = {}, - onAddMemberClick = {}, members = listOf(), showAddMembers = true, - showingError = "Error", - showLoading = false, searchQuery = "", - searchFocused = true, data = CollapsibleFooterState( visible = false, collapsed = true, @@ -644,9 +620,7 @@ private fun EditGroupEmptyPreview( ) ), selectedMembers = emptySet(), - showingResend = "Resending Invite", sendCommand = {}, - removeMembersData = ManageGroupMembersViewModel.RemoveMembersState(), uiState = ManageGroupMembersViewModel.UiState(options = emptyList()), hasMembers = true, ) From 97ec5850cba69b43d705ec197e84600e99079a0d Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 10 Nov 2025 11:03:21 +0800 Subject: [PATCH 46/50] Added footer to the UiState --- .../groups/ManageGroupMembersViewModel.kt | 103 ++++++++-------- .../compose/ManageGroupMembersScreen.kt | 116 +++++++++--------- 2 files changed, 114 insertions(+), 105 deletions(-) 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 de329ac4e3..e40099c29d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -95,57 +95,24 @@ class ManageGroupMembersViewModel @AssistedInject constructor( private val _uiState = MutableStateFlow(UiState(options = optionsList)) val uiState: StateFlow = _uiState - val collapsibleFooterState: StateFlow = - combine(_mutableSelectedMembers, footerCollapsed) { selected, isCollapsed -> - val count = selected.size - val visible = count > 0 - val title = if (count == 0) GetString("") - else GetString( - context.resources.getQuantityString(R.plurals.memberSelected, count, count) - ) - - // build tray items - val trayItems = listOf( - CollapsibleFooterItemData( - label = GetString( - context.resources.getQuantityString(R.plurals.resendInvite, count, count) - ), - buttonLabel = GetString(context.getString(R.string.resend)), - isDanger = false, - onClick = { onResendInviteClicked() } - ), - CollapsibleFooterItemData( - label = GetString( - context.resources.getQuantityString(R.plurals.removeMember, count, count) - ), - buttonLabel = GetString(context.getString(R.string.remove)), - isDanger = true, - onClick = {onCommand(Commands.ShowRemoveDialog)} - ) - ) - - CollapsibleFooterState( - visible = visible, - collapsed = if (!visible) false else isCollapsed, - footerActionTitle = title, - footerActionItems = trayItems - ) - } - .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState()) - private val showRemoveMembersDialog = MutableStateFlow(false) init { - combine( - showRemoveMembersDialog, - selectedMembers, - groupName - ) { showRemove, selected, group -> - buildRemoveMembersDialogState(showRemove, selected, group) - }.onEach { state -> - _uiState.update { it.copy(removeMembersDialog = state) } - }.launchIn(viewModelScope) + viewModelScope.launch { + combine(showRemoveMembersDialog, selectedMembers, groupName) { showRemove, selected, group -> + buildRemoveMembersDialogState(showRemove, selected, group) + }.collect { state -> + _uiState.update { it.copy(removeMembersDialog = state) } + } + } + + viewModelScope.launch { + combine(selectedMembers, footerCollapsed) { selected, isCollapsed -> + buildFooterState(selected, isCollapsed) + }.collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } } fun onMemberItemClicked(member: GroupMemberState) { val newSet = _mutableSelectedMembers.value.toHashSet() @@ -402,6 +369,43 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ) } + 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 trayItems = listOf( + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.resendInvite, count, count) + ), + buttonLabel = GetString(context.getString(R.string.resend)), + isDanger = false, + onClick = { onResendInviteClicked() } + ), + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.removeMember, count, count) + ), + buttonLabel = GetString(context.getString(R.string.remove)), + isDanger = true, + onClick = { onCommand(Commands.ShowRemoveDialog) } + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title, + footerActionItems = trayItems + ) + } + data class UiState( val options : List = emptyList(), @@ -415,6 +419,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( // Remove member dialog val removeMembersDialog: RemoveMembersDialogState = RemoveMembersDialogState(), + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState() ) data class CollapsibleFooterState( 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 53b3161213..c4f5427c3b 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 @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.groups.compose +import android.R.attr.data import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -94,7 +95,6 @@ fun ManageGroupMembersScreen( selectedMembers = viewModel.selectedMembers.collectAsState().value, showAddMembers = viewModel.showAddMembers.collectAsState().value, searchQuery = viewModel.searchQuery.collectAsState().value, - data = viewModel.collapsibleFooterState.collectAsState().value, sendCommand = viewModel::onCommand, ) } @@ -105,13 +105,11 @@ fun ManageMembers( onBack: () -> Unit, uiState: ManageGroupMembersViewModel.UiState, searchQuery: String, - data: CollapsibleFooterState, members: List, hasMembers: Boolean = false, selectedMembers: Set = emptySet(), showAddMembers: Boolean, sendCommand: (command: ManageGroupMembersViewModel.Commands) -> Unit, -// removeMembersData: ManageGroupMembersViewModel.RemoveMembersDialogState, ) { val searchFocused = uiState.isSearchFocused @@ -144,12 +142,12 @@ fun ManageMembers( ) { CollapsibleFooterAction( data = CollapsibleFooterActionData( - title = data.footerActionTitle, - collapsed = data.collapsed, - visible = data.visible, - items = data.footerActionItems + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = uiState.footer.footerActionItems ), - onCollapsedClicked = {sendCommand(ToggleFooter)}, + onCollapsedClicked = { sendCommand(ToggleFooter) }, onClosedClicked = { sendCommand(CloseFooter) } ) } @@ -263,8 +261,8 @@ fun ManageMembers( } } } - - if(uiState.removeMembersDialog.visible){ + + if (uiState.removeMembersDialog.visible) { ShowRemoveMembersDialog( state = uiState.removeMembersDialog, sendCommand = sendCommand @@ -470,15 +468,17 @@ private fun EditGroupPreviewSheet() { members = listOf(oneMember, twoMember, threeMember), showAddMembers = true, searchQuery = "Test", - data = CollapsibleFooterState( - visible = true, - collapsed = false, - footerActionTitle = title, - footerActionItems = trayItems - ), selectedMembers = emptySet(), sendCommand = {}, - uiState = ManageGroupMembersViewModel.UiState(options = emptyList()), + uiState = ManageGroupMembersViewModel.UiState( + options = emptyList(), + footer = CollapsibleFooterState( + visible = true, + collapsed = false, + footerActionTitle = title, + footerActionItems = trayItems + ) + ), hasMembers = true, ) } @@ -562,28 +562,29 @@ private fun EditGroupEditNamePreview( members = listOf(oneMember, twoMember, threeMember), showAddMembers = true, searchQuery = "", - data = CollapsibleFooterState( - visible = true, - collapsed = false, - footerActionTitle = GetString("3 Members Selected"), - footerActionItems = listOf( - CollapsibleFooterItemData( - label = GetString("Resend"), - buttonLabel = GetString("1"), - isDanger = false, - onClick = {} - ), - CollapsibleFooterItemData( - label = GetString("Remove"), - buttonLabel = GetString("1"), - isDanger = true, - onClick = { } - ) - ) - ), selectedMembers = emptySet(), sendCommand = {}, - uiState = ManageGroupMembersViewModel.UiState(options = emptyList()), + uiState = ManageGroupMembersViewModel.UiState( + options = emptyList(), + footer = CollapsibleFooterState( + visible = true, + collapsed = false, + footerActionTitle = GetString("3 Members Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), hasMembers = true, ) } @@ -600,28 +601,29 @@ private fun EditGroupEmptyPreview( members = listOf(), showAddMembers = true, searchQuery = "", - data = CollapsibleFooterState( - visible = false, - collapsed = true, - footerActionTitle = GetString("3 Members Selected"), - footerActionItems = listOf( - CollapsibleFooterItemData( - label = GetString("Resend"), - buttonLabel = GetString("1"), - isDanger = false, - onClick = {} - ), - CollapsibleFooterItemData( - label = GetString("Remove"), - buttonLabel = GetString("1"), - isDanger = true, - onClick = { } - ) - ) - ), selectedMembers = emptySet(), sendCommand = {}, - uiState = ManageGroupMembersViewModel.UiState(options = emptyList()), + uiState = ManageGroupMembersViewModel.UiState( + options = emptyList(), + footer = CollapsibleFooterState( + visible = false, + collapsed = true, + footerActionTitle = GetString("3 Members Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), hasMembers = true, ) } From 6731b932ea1dbf9f69b5796d1e14999b465bc832 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 10 Nov 2025 11:31:15 +0800 Subject: [PATCH 47/50] updated icon --- app/src/main/res/drawable/ic_user_round_search.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/drawable/ic_user_round_search.xml b/app/src/main/res/drawable/ic_user_round_search.xml index c192ef068a..df48dae1b1 100644 --- a/app/src/main/res/drawable/ic_user_round_search.xml +++ b/app/src/main/res/drawable/ic_user_round_search.xml @@ -1,11 +1,11 @@ - + - + - + - + From 043e79d68c2829ddc1679247d577569d5ad4efbc Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 11 Nov 2025 07:33:41 +0800 Subject: [PATCH 48/50] persist job --- .../securesms/groups/GroupManagerV2Impl.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) 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 82623a4f4c..e0e3ef7d01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -303,6 +303,14 @@ class GroupManagerV2Impl @Inject constructor( subAccountTokens = subAccountTokens ) + // Send the invitation message to the new members + JobQueue.shared.add( + InviteContactsJob( + group.hexString, + memberInvites.map { it.id.hexString }.toTypedArray(), isReinvite + ) + ) + try { val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) @@ -339,17 +347,8 @@ class GroupManagerV2Impl @Inject constructor( sendGroupUpdateForAddingMembers(group, adminKey, memberInvites.map { it.id }) } } - - // Send the invitation message to the new members - JobQueue.shared.add( - InviteContactsJob( - group.hexString, - memberInvites.map { it.id.hexString }.toTypedArray(), isReinvite - ) - ) } - /** * Send a group update message to the group telling members someone has been invited. */ From d869eb7a2eaa583900f959478985297a8deb93a2 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 11 Nov 2025 07:55:29 +0800 Subject: [PATCH 49/50] Add InviteContactsJob Factory --- .../messaging/jobs/InviteContactsJob.kt | 51 ++++++++++++++++--- .../jobs/SessionJobManagerFactories.kt | 4 +- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 9109309e93..2db644a703 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -2,6 +2,9 @@ package org.session.libsession.messaging.jobs import android.widget.Toast import com.google.protobuf.ByteString +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -22,12 +25,17 @@ import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateM import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array, val isReinvite : Boolean) : Job { +class InviteContactsJob @AssistedInject constructor( + @Assisted val groupSessionId: String, + @Assisted val memberSessionIds: Array, + @Assisted val isReinvite: Boolean +) : Job { companion object { const val KEY = "InviteContactJob" private const val GROUP = "group" private const val MEMBER = "member" + private const val REINVITE = "reinvite" } @@ -55,7 +63,9 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< // Make the request for this member val memberId = AccountId(memberSessionId) val (groupName, subAccount) = configs.withMutableGroupConfigs(sessionId) { configs -> - configs.groupInfo.getName() to configs.groupKeys.makeSubAccount(memberSessionId) + configs.groupInfo.getName() to configs.groupKeys.makeSubAccount( + memberSessionId + ) } val timestamp = SnodeAPI.nowWithOffset @@ -76,7 +86,11 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< sentTimestamp = timestamp } - MessageSender.sendNonDurably(update, Destination.Contact(memberSessionId), false) + MessageSender.sendNonDurably( + update, + Destination.Contact(memberSessionId), + false + ) } } } @@ -123,10 +137,16 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< groupName = groupName.orEmpty(), underlying = firstError, isReinvite = isReinvite - ).format(MessagingModuleConfiguration.shared.context, - MessagingModuleConfiguration.shared.recipientRepository).let { + ).format( + MessagingModuleConfiguration.shared.context, + MessagingModuleConfiguration.shared.recipientRepository + ).let { withContext(Dispatchers.Main) { - Toast.makeText(MessagingModuleConfiguration.shared.context, it, Toast.LENGTH_LONG).show() + Toast.makeText( + MessagingModuleConfiguration.shared.context, + it, + Toast.LENGTH_LONG + ).show() } } } @@ -137,8 +157,27 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< Data.Builder() .putString(GROUP, groupSessionId) .putStringArray(MEMBER, memberSessionIds) + .putBoolean(REINVITE, isReinvite) .build() override fun getFactoryKey(): String = KEY + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + + // Deserialization path used by SessionJobDatabase -> SessionJobInstantiator + override fun create(data: Data): InviteContactsJob { + val group = data.getString(GROUP) + val members = data.getStringArray(MEMBER) + val reinvite = data.getBooleanOrDefault(REINVITE, false) + return create(group, members, reinvite) + } + + // This is what you call at runtime to create a new job (Dagger injects the rest) + abstract fun create( + groupSessionId: String, + memberSessionIds: Array, + isReinvite: Boolean + ): InviteContactsJob + } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index cd0468d9f2..e8c94b75cc 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -8,7 +8,8 @@ class SessionJobManagerFactories @Inject constructor( private val batchFactory: BatchMessageReceiveJob.Factory, private val trimThreadFactory: TrimThreadJob.Factory, private val messageSendJobFactory: MessageSendJob.Factory, - private val deleteJobFactory: OpenGroupDeleteJob.Factory + private val deleteJobFactory: OpenGroupDeleteJob.Factory, + private val inviteContactsJobFactory: InviteContactsJob.Factory ) { fun getSessionJobFactories(): Map> { @@ -20,6 +21,7 @@ class SessionJobManagerFactories @Inject constructor( TrimThreadJob.KEY to trimThreadFactory, BatchMessageReceiveJob.KEY to batchFactory, OpenGroupDeleteJob.KEY to deleteJobFactory, + InviteContactsJob.KEY to inviteContactsJobFactory ) } } \ No newline at end of file From 315656c11ccccdee00a9d057f1e0abb58c44c1c8 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 14 Nov 2025 09:29:25 +0800 Subject: [PATCH 50/50] String ref fix --- .../securesms/groups/compose/ManageGroupMembersScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c4f5427c3b..23fc9d7360 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 @@ -253,7 +253,7 @@ fun ManageMembers( .padding(horizontal = LocalDimensions.current.mediumSpacing) .fillMaxWidth() .wrapContentWidth(Alignment.CenterHorizontally), - text = LocalResources.current.getString(R.string.NoNonAdminsInGroup), + text = LocalResources.current.getString(R.string.noNonAdminsInGroup), textAlign = TextAlign.Center, style = LocalType.current.base, color = LocalColors.current.textSecondary