From 05b851b90e64d8ec1b72f2064b90b42fcdb13ac4 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 10 Nov 2025 16:23:41 +0800 Subject: [PATCH 01/17] empty contacts screen fix --- .../securesms/groups/compose/InviteContactsScreen.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 51db502057..2453b4986b 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address @@ -150,13 +151,14 @@ fun InviteContacts( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - Box(modifier = Modifier.weight(1f)) { + Box(modifier = Modifier.weight(1f) + .fillMaxWidth()) { if (contacts.isEmpty() && searchQuery.isEmpty()) { Text( - text = stringResource(id = R.string.contactNone), + text = stringResource(id = R.string.membersInviteNoContacts), modifier = Modifier - .padding(top = LocalDimensions.current.spacing) .align(Alignment.TopCenter), + textAlign = TextAlign.Center, style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) ) } else { From 0ba1fef7cfe4dba4979089b7b39c922399a043cd Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 10 Nov 2025 17:33:01 +0800 Subject: [PATCH 02/17] Send commands cleanup for invite contact --- .../groups/SelectContactsViewModel.kt | 45 +++++++++++- .../groups/compose/InviteContactsScreen.kt | 70 ++++++++----------- 2 files changed, 73 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index cc371c5164..b686d13bd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -30,6 +30,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.UiState import org.thoughtcrime.securesms.home.search.searchName import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.ui.GetString @@ -47,9 +48,6 @@ open class SelectContactsViewModel @AssistedInject constructor( private val recipientRepository: RecipientRepository, @param:ApplicationContext private val context: Context, ) : ViewModel() { - // Input: The search query - private val mutableSearchQuery = MutableStateFlow("") - // Input: The selected contact account IDs private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet
()) @@ -57,6 +55,8 @@ open class SelectContactsViewModel @AssistedInject constructor( // the user has. This is useful for selecting contacts that are not in the user's contacts list. private val mutableManuallyAddedContacts = MutableStateFlow(emptySet
()) + // Input: The search query + private val mutableSearchQuery = MutableStateFlow("") // Output: The search query val searchQuery: StateFlow get() = mutableSearchQuery @@ -74,6 +74,9 @@ open class SelectContactsViewModel @AssistedInject constructor( .map { it.isNotEmpty() } .stateIn(viewModelScope, SharingStarted.Eagerly, false) + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + // Output val currentSelected: Set
get() = mutableSelectedContactAccountIDs.value @@ -176,12 +179,48 @@ open class SelectContactsViewModel @AssistedInject constructor( footerCollapsed.update { !it } } + fun onSearchFocusChanged(isFocused :Boolean){ + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + fun sendCommand(command: Commands) { + when (command) { + is Commands.ClearSelection -> clearSelection() + is Commands.ToggleFooter -> toggleFooter() + is Commands.CloseFooter -> clearSelection() + is Commands.ContactItemClick -> onContactItemClicked(command.address) + is Commands.RemoveSearchState -> onSearchFocusChanged(false) + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + data class UiState( + val isSearchFocused : Boolean = false, + ) + data class CollapsibleFooterState( val visible: Boolean = false, val collapsed: Boolean = false, val footerActionTitle : GetString = GetString("") ) + sealed interface Commands { + data object ClearSelection : Commands + + data object ToggleFooter : Commands + + data object CloseFooter : Commands + + data class ContactItemClick(val address: Address) : Commands + + data class RemoveSearchState(val clearSelection: Boolean) : Commands + + data class SearchQueryChange(val query: String) : Commands + + data class SearchFocusChange(val focus: Boolean) : Commands + } + @AssistedFactory interface Factory { fun create( 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 2453b4986b..7f3081bb0a 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 @@ -32,12 +32,12 @@ import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem import org.thoughtcrime.securesms.groups.SelectContactsViewModel -import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.groups.SelectContactsViewModel.Commands.* import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.SearchBarWithClose import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -60,17 +60,13 @@ fun InviteContactsScreen( InviteContacts( contacts = viewModel.contacts.collectAsState().value, - onContactItemClicked = viewModel::onContactItemClicked, + uiState = viewModel.uiState.collectAsState().value, searchQuery = viewModel.searchQuery.collectAsState().value, - onSearchQueryChanged = viewModel::onSearchQueryChanged, - onSearchQueryClear = { viewModel.onSearchQueryChanged("") }, onDoneClicked = onDoneClicked, onBack = onBack, banner = banner, data = footerData, - onToggleFooter = viewModel::toggleFooter, - onCloseFooter = viewModel::clearSelection - + sendCommand = viewModel::sendCommand ) } @@ -78,16 +74,14 @@ fun InviteContactsScreen( @Composable fun InviteContacts( contacts: List, - onContactItemClicked: (address: Address) -> Unit, - searchQuery: String, - onSearchQueryChanged: (String) -> Unit, - onSearchQueryClear: () -> Unit, + uiState : SelectContactsViewModel.UiState, + searchQuery : String, onDoneClicked: () -> Unit, onBack: () -> Unit, banner: @Composable () -> Unit = {}, data: SelectContactsViewModel.CollapsibleFooterState, - onToggleFooter: () -> Unit, - onCloseFooter: () -> Unit, + sendCommand : (command : SelectContactsViewModel.Commands) -> Unit + ) { val trayItems = listOf( @@ -121,8 +115,8 @@ fun InviteContacts( visible = data.visible, items = trayItems ), - onCollapsedClicked = onToggleFooter, - onClosedClicked = onCloseFooter + onCollapsedClicked = {sendCommand(ToggleFooter)}, + onClosedClicked = {sendCommand(CloseFooter)} ) } } @@ -136,22 +130,26 @@ fun InviteContacts( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - SearchBar( + SearchBarWithClose( query = searchQuery, - onValueChanged = onSearchQueryChanged, - onClear = onSearchQueryClear, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, placeholder = stringResource(R.string.searchContacts), modifier = Modifier .padding(horizontal = LocalDimensions.current.smallSpacing) .qaTag(R.string.AccessibilityId_groupNameSearch), backgroundColor = LocalColors.current.backgroundSecondary, + isFocused = uiState.isSearchFocused, + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) }, + enabled = true, ) val scrollState = rememberLazyListState() Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - Box(modifier = Modifier.weight(1f) + Box(modifier = Modifier + .weight(1f) .fillMaxWidth()) { if (contacts.isEmpty() && searchQuery.isEmpty()) { Text( @@ -168,7 +166,7 @@ fun InviteContacts( ) { multiSelectMemberList( contacts = contacts, - onContactItemClicked = onContactItemClicked, + onContactItemClicked = { address -> sendCommand(ContactItemClick(address)) }, ) } } @@ -201,10 +199,6 @@ private fun PreviewSelectContacts() { PreviewTheme { InviteContacts( contacts = contacts, - onContactItemClicked = {}, - searchQuery = "", - onSearchQueryChanged = {}, - onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, data = SelectContactsViewModel.CollapsibleFooterState( @@ -212,8 +206,10 @@ private fun PreviewSelectContacts() { visible = true, footerActionTitle = GetString("1 Contact Selected") ), - onToggleFooter = { }, - onCloseFooter = { }, + banner = {}, + sendCommand = {}, + uiState = SelectContactsViewModel.UiState(), + searchQuery = "" ) } } @@ -226,10 +222,6 @@ private fun PreviewSelectEmptyContacts() { PreviewTheme { InviteContacts( contacts = contacts, - onContactItemClicked = {}, - searchQuery = "", - onSearchQueryChanged = {}, - onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, data = SelectContactsViewModel.CollapsibleFooterState( @@ -237,8 +229,10 @@ private fun PreviewSelectEmptyContacts() { visible = false, footerActionTitle = GetString("") ), - onToggleFooter = { }, - onCloseFooter = { } + banner = {}, + sendCommand = {}, + uiState = SelectContactsViewModel.UiState(), + searchQuery = "Test" ) } } @@ -251,10 +245,6 @@ private fun PreviewSelectEmptyContactsWithSearch() { PreviewTheme { InviteContacts( contacts = contacts, - onContactItemClicked = {}, - searchQuery = "Test", - onSearchQueryChanged = {}, - onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, data = SelectContactsViewModel.CollapsibleFooterState( @@ -262,8 +252,10 @@ private fun PreviewSelectEmptyContactsWithSearch() { visible = false, footerActionTitle = GetString("") ), - onToggleFooter = { }, - onCloseFooter = { } + banner = {}, + sendCommand = {}, + uiState = SelectContactsViewModel.UiState(), + searchQuery = "" ) } } From 4c8a2bf430c386a7f5c12a2efeaab350a1607992 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 11 Nov 2025 15:38:56 +0800 Subject: [PATCH 03/17] Added selected contact data with name --- .../groups/ManageGroupMembersViewModel.kt | 11 ++ .../groups/SelectContactsViewModel.kt | 165 ++++++++++++++---- .../groups/compose/InviteContactsScreen.kt | 91 ++++++---- 3 files changed, 198 insertions(+), 69 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 e40099c29d..c118d5c7a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -137,6 +137,17 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } fun onContactSelected(contacts: Set
) { + val selectedCount = selectedMembers.value.size + _uiState.update { + it.copy( + ongoingAction = context.resources.getQuantityString( + R.plurals.groupInviteSending, + selectedCount, + selectedCount + ) + ) + } + performGroupOperation( showLoading = false, errorMessage = { err -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index b686d13bd3..663a94508c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -17,20 +18,22 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.Address +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.UiState import org.thoughtcrime.securesms.home.search.searchName import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.ui.GetString @@ -49,7 +52,7 @@ open class SelectContactsViewModel @AssistedInject constructor( @param:ApplicationContext private val context: Context, ) : ViewModel() { // Input: The selected contact account IDs - private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet
()) + private val mutableSelectedContacts = MutableStateFlow(emptySet()) // Input: The manually added items to select from. This will be combined (and deduped) with the contacts // the user has. This is useful for selecting contacts that are not in the user's contacts list. @@ -66,7 +69,7 @@ open class SelectContactsViewModel @AssistedInject constructor( val contacts: StateFlow> = combine( contactsFlow, mutableSearchQuery.debounce(100L), - mutableSelectedContactAccountIDs, + mutableSelectedContacts, ::filterContacts ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) @@ -79,28 +82,28 @@ open class SelectContactsViewModel @AssistedInject constructor( // Output val currentSelected: Set
- get() = mutableSelectedContactAccountIDs.value + get() = mutableSelectedContacts.value.map { it.address }.toSet() private val footerCollapsed = MutableStateFlow(false) + private val showInviteContactsDialog = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine(mutableSelectedContacts, footerCollapsed) { selected, isCollapsed -> + buildFooterState(selected, isCollapsed) + }.collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } - val collapsibleFooterState: StateFlow = - combine(mutableSelectedContactAccountIDs, footerCollapsed) { selected, isCollapsed -> - val count = selected.size - val visible = count > 0 - val title = if (count == 0) GetString("") - else GetString( - context.resources.getQuantityString(R.plurals.contactSelected, count, count) - ) - - CollapsibleFooterState( - visible = visible, - // auto-expand when nothing is selected, otherwise keep user's choice - collapsed = if (!visible) false else isCollapsed, - footerActionTitle = title - ) + viewModelScope.launch { + combine(showInviteContactsDialog, mutableSelectedContacts) { showDialog, selected -> + buildInviteContactsDialogState(showDialog, selected) + }.collect { state -> + _uiState.update { it.copy(inviteContactsDialog = state) } + } } - .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState()) + } @OptIn(ExperimentalCoroutinesApi::class) private fun observeContacts() = (configFactory.configUpdateNotifications as Flow) @@ -131,9 +134,10 @@ open class SelectContactsViewModel @AssistedInject constructor( private fun filterContacts( contacts: Collection, query: String, - selectedAccountIDs: Set
+ selectedContacts: Set ): List { val items = mutableListOf() + val selectedAddresses = selectedContacts.asSequence().map { it.address }.toSet() for (contact in contacts) { if (query.isBlank() || contact.searchName.contains(query, ignoreCase = true)) { val avatarData = avatarUtils.getUIDataFromRecipient(contact) @@ -142,7 +146,7 @@ open class SelectContactsViewModel @AssistedInject constructor( name = contact.searchName, address = contact.address, avatarUIData = avatarData, - selected = selectedAccountIDs.contains(contact.address), + selected = selectedAddresses.contains(contact.address), showProBadge = contact.proStatus.shouldShowProBadge() ) ) @@ -160,19 +164,25 @@ open class SelectContactsViewModel @AssistedInject constructor( } open fun onContactItemClicked(address: Address) { - val newSet = mutableSelectedContactAccountIDs.value.toHashSet() - if (!newSet.remove(address)) { - newSet.add(address) + val newSet = mutableSelectedContacts.value.toHashSet() + val selectedContact = contacts.value.find { it.address == address } + + if(selectedContact == null) return + + val item = SelectedContact(address = selectedContact.address, name = selectedContact.name) + if (!newSet.remove(item)) { + newSet.add(item) } - mutableSelectedContactAccountIDs.value = newSet + mutableSelectedContacts.value = newSet } fun selectAccountIDs(accountIDs: Set
) { - mutableSelectedContactAccountIDs.value += accountIDs + val toAdd = accountIDs.map { address -> SelectedContact(address) }.toSet() + mutableSelectedContacts.update { (it + toAdd).toSet() } } fun clearSelection(){ - mutableSelectedContactAccountIDs.value = emptySet() + mutableSelectedContacts.value = emptySet() } fun toggleFooter() { @@ -183,20 +193,100 @@ open class SelectContactsViewModel @AssistedInject constructor( _uiState.update { it.copy(isSearchFocused = isFocused) } } + fun onDismissResend() { + _uiState.update { it.copy(ongoingAction = null) } + } + + fun removeSearchState(clearSelection : Boolean){ + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if(clearSelection){ + clearSelection() + } + } + + 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.contactSelected, count, count) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title + ) + } + + private fun buildInviteContactsDialogState( + visible: Boolean, + selected : Set + ): InviteContactsDialogState { + val count = selected.size + val firstMember = selected.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> Phrase.from(context, R.string.membersInviteShareDescription) + .put(NAME_KEY, firstMember?.name) + .format() + + 2 -> { + val secondMember = selected.elementAtOrNull(1)?.name + Phrase.from(context, R.string.membersInviteShareDescriptionTwo) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.membersInviteShareDescriptionMultiple) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .format() + } + + return InviteContactsDialogState( + visible = visible, + inviteContactsBody = body, + ) + } + + fun toggleInviteContactsDialog(visible : Boolean){ + showInviteContactsDialog.value = visible + } + fun sendCommand(command: Commands) { when (command) { is Commands.ClearSelection -> clearSelection() is Commands.ToggleFooter -> toggleFooter() is Commands.CloseFooter -> clearSelection() + is Commands.DismissResend -> onDismissResend() + is Commands.ShowSendInvite -> toggleInviteContactsDialog(true) + is Commands.DismissSendInvite -> toggleInviteContactsDialog(false) is Commands.ContactItemClick -> onContactItemClicked(command.address) - is Commands.RemoveSearchState -> onSearchFocusChanged(false) + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) } } data class UiState( - val isSearchFocused : Boolean = false, + val isSearchFocused: Boolean = false, + val ongoingAction: String? = null, + + val inviteContactsDialog: InviteContactsDialogState = InviteContactsDialogState(), + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class InviteContactsDialogState( + val visible : Boolean = false, + val inviteContactsBody : CharSequence = "", ) data class CollapsibleFooterState( @@ -212,6 +302,12 @@ open class SelectContactsViewModel @AssistedInject constructor( data object CloseFooter : Commands + data object DismissResend : Commands + + data object ShowSendInvite : Commands + + data object DismissSendInvite : Commands + data class ContactItemClick(val address: Address) : Commands data class RemoveSearchState(val clearSelection: Boolean) : Commands @@ -241,3 +337,8 @@ data class ContactItem( val selected: Boolean, val showProBadge: Boolean ) + +data class SelectedContact( + val address: Address, + val name: String = "" +) 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 7f3081bb0a..d3ae55affd 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 @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.groups.compose +import android.R.attr.data +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -31,6 +33,7 @@ import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.RemoveSearchState import org.thoughtcrime.securesms.groups.SelectContactsViewModel import org.thoughtcrime.securesms.groups.SelectContactsViewModel.Commands.* import org.thoughtcrime.securesms.ui.CollapsibleFooterAction @@ -56,8 +59,6 @@ fun InviteContactsScreen( onBack: () -> Unit, banner: @Composable () -> Unit = {} ) { - val footerData by viewModel.collapsibleFooterState.collectAsState() - InviteContacts( contacts = viewModel.contacts.collectAsState().value, uiState = viewModel.uiState.collectAsState().value, @@ -65,7 +66,6 @@ fun InviteContactsScreen( onDoneClicked = onDoneClicked, onBack = onBack, banner = banner, - data = footerData, sendCommand = viewModel::sendCommand ) } @@ -74,13 +74,12 @@ fun InviteContactsScreen( @Composable fun InviteContacts( contacts: List, - uiState : SelectContactsViewModel.UiState, - searchQuery : String, + uiState: SelectContactsViewModel.UiState, + searchQuery: String, onDoneClicked: () -> Unit, onBack: () -> Unit, banner: @Composable () -> Unit = {}, - data: SelectContactsViewModel.CollapsibleFooterState, - sendCommand : (command : SelectContactsViewModel.Commands) -> Unit + sendCommand: (command: SelectContactsViewModel.Commands) -> Unit ) { @@ -89,16 +88,31 @@ fun InviteContacts( label = GetString(LocalResources.current.getString(R.string.membersInvite)), buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)), isDanger = false, - onClick = { onDoneClicked() } + onClick = { sendCommand(ShowSendInvite) } ) ) + val handleBack: () -> Unit = { + when { + uiState.isSearchFocused -> sendCommand( + SelectContactsViewModel.Commands.RemoveSearchState( + false + ) + ) + + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { BackAppBar( title = stringResource(id = R.string.membersInvite), - onBack = onBack, + onBack = handleBack, ) }, bottomBar = { @@ -110,13 +124,13 @@ fun InviteContacts( ) { CollapsibleFooterAction( data = CollapsibleFooterActionData( - title = data.footerActionTitle, - collapsed = data.collapsed, - visible = data.visible, + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, items = trayItems ), - onCollapsedClicked = {sendCommand(ToggleFooter)}, - onClosedClicked = {sendCommand(CloseFooter)} + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } ) } } @@ -126,8 +140,6 @@ fun InviteContacts( .padding(paddings) .consumeWindowInsets(paddings), ) { - banner() - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) SearchBarWithClose( @@ -148,9 +160,11 @@ fun InviteContacts( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - Box(modifier = Modifier - .weight(1f) - .fillMaxWidth()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { if (contacts.isEmpty() && searchQuery.isEmpty()) { Text( text = stringResource(id = R.string.membersInviteNoContacts), @@ -201,14 +215,15 @@ private fun PreviewSelectContacts() { contacts = contacts, onDoneClicked = {}, onBack = {}, - data = SelectContactsViewModel.CollapsibleFooterState( - collapsed = false, - visible = true, - footerActionTitle = GetString("1 Contact Selected") - ), banner = {}, sendCommand = {}, - uiState = SelectContactsViewModel.UiState(), + uiState = SelectContactsViewModel.UiState( + footer = SelectContactsViewModel.CollapsibleFooterState( + collapsed = false, + visible = true, + footerActionTitle = GetString("1 Contact Selected") + ) + ), searchQuery = "" ) } @@ -224,14 +239,15 @@ private fun PreviewSelectEmptyContacts() { contacts = contacts, onDoneClicked = {}, onBack = {}, - data = SelectContactsViewModel.CollapsibleFooterState( - collapsed = true, - visible = false, - footerActionTitle = GetString("") - ), banner = {}, sendCommand = {}, - uiState = SelectContactsViewModel.UiState(), + uiState = SelectContactsViewModel.UiState( + footer = SelectContactsViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ) + ), searchQuery = "Test" ) } @@ -247,14 +263,15 @@ private fun PreviewSelectEmptyContactsWithSearch() { contacts = contacts, onDoneClicked = {}, onBack = {}, - data = SelectContactsViewModel.CollapsibleFooterState( - collapsed = true, - visible = false, - footerActionTitle = GetString("") - ), banner = {}, sendCommand = {}, - uiState = SelectContactsViewModel.UiState(), + uiState = SelectContactsViewModel.UiState( + footer = SelectContactsViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ) + ), searchQuery = "" ) } From dcf0b7c1ff05ce861670fa0875f0dce4ccd158f9 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 11 Nov 2025 16:22:40 +0800 Subject: [PATCH 04/17] Code cleanup, Dialog for invite --- .../settings/ConversationSettingsNavHost.kt | 5 +- .../groups/ManageGroupMembersViewModel.kt | 10 +-- .../groups/SelectContactsViewModel.kt | 15 ++-- .../groups/compose/InviteContactsScreen.kt | 82 +++++++++++++++++-- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 4dcc5837e4..65660a85af 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 @@ -214,10 +214,9 @@ fun ConversationSettingsNavHost( InviteContactsScreen( viewModel = viewModel, - onDoneClicked = dropUnlessResumed { + onDoneClicked = { shareHistory -> //send invites from the manage group screen - manageGroupMembersViewModel.onContactSelected(viewModel.currentSelected) - + manageGroupMembersViewModel.onSendInviteClicked(viewModel.currentSelected) handleBack() }, onBack = dropUnlessResumed { 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 c118d5c7a5..f8486ed18b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -16,10 +16,7 @@ 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.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 @@ -136,14 +133,13 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onContactSelected(contacts: Set
) { - val selectedCount = selectedMembers.value.size + fun onSendInviteClicked(contacts: Set
) { _uiState.update { it.copy( ongoingAction = context.resources.getQuantityString( R.plurals.groupInviteSending, - selectedCount, - selectedCount + contacts.size, + contacts.size ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 663a94508c..702c30c159 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -226,7 +226,7 @@ open class SelectContactsViewModel @AssistedInject constructor( private fun buildInviteContactsDialogState( visible: Boolean, - selected : Set + selected: Set ): InviteContactsDialogState { val count = selected.size val firstMember = selected.firstOrNull() @@ -251,9 +251,13 @@ open class SelectContactsViewModel @AssistedInject constructor( .format() } + val inviteText = + context.resources.getQuantityString(R.plurals.membersInviteSend, count, count) + return InviteContactsDialogState( visible = visible, inviteContactsBody = body, + inviteText = inviteText ) } @@ -267,8 +271,8 @@ open class SelectContactsViewModel @AssistedInject constructor( is Commands.ToggleFooter -> toggleFooter() is Commands.CloseFooter -> clearSelection() is Commands.DismissResend -> onDismissResend() - is Commands.ShowSendInvite -> toggleInviteContactsDialog(true) - is Commands.DismissSendInvite -> toggleInviteContactsDialog(false) + is Commands.ShowSendInviteDialog -> toggleInviteContactsDialog(true) + is Commands.DismissSendInviteDialog -> toggleInviteContactsDialog(false) is Commands.ContactItemClick -> onContactItemClicked(command.address) is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) @@ -287,6 +291,7 @@ open class SelectContactsViewModel @AssistedInject constructor( data class InviteContactsDialogState( val visible : Boolean = false, val inviteContactsBody : CharSequence = "", + val inviteText : String = "" ) data class CollapsibleFooterState( @@ -304,9 +309,9 @@ open class SelectContactsViewModel @AssistedInject constructor( data object DismissResend : Commands - data object ShowSendInvite : Commands + data object ShowSendInviteDialog : Commands - data object DismissSendInvite : Commands + data object DismissSendInviteDialog : Commands data class ContactItemClick(val address: Address) : Commands 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 d3ae55affd..0f8beb8324 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 @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.groups.compose -import android.R.attr.data import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,6 +23,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.Modifier import androidx.compose.ui.platform.LocalResources @@ -33,15 +35,19 @@ import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem -import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.RemoveSearchState import org.thoughtcrime.securesms.groups.SelectContactsViewModel import org.thoughtcrime.securesms.groups.SelectContactsViewModel.Commands.* +import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.SearchBarWithClose import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton +import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -55,7 +61,7 @@ import org.thoughtcrime.securesms.util.AvatarUIElement @Composable fun InviteContactsScreen( viewModel: SelectContactsViewModel, - onDoneClicked: () -> Unit, + onDoneClicked: (shareHistory : Boolean) -> Unit, onBack: () -> Unit, banner: @Composable () -> Unit = {} ) { @@ -76,7 +82,7 @@ fun InviteContacts( contacts: List, uiState: SelectContactsViewModel.UiState, searchQuery: String, - onDoneClicked: () -> Unit, + onDoneClicked: (shareHistory : Boolean) -> Unit, onBack: () -> Unit, banner: @Composable () -> Unit = {}, sendCommand: (command: SelectContactsViewModel.Commands) -> Unit @@ -88,7 +94,7 @@ fun InviteContacts( label = GetString(LocalResources.current.getString(R.string.membersInvite)), buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)), isDanger = false, - onClick = { sendCommand(ShowSendInvite) } + onClick = { sendCommand(ShowSendInviteDialog) } ) ) @@ -187,6 +193,72 @@ fun InviteContacts( } } } + + if (uiState.inviteContactsDialog.visible) { + ShowInviteContactsDialog( + state = uiState.inviteContactsDialog, + onDoneClicked = onDoneClicked, + sendCommand = sendCommand + ) + } +} + +@Composable +fun ShowInviteContactsDialog( + state: SelectContactsViewModel.InviteContactsDialogState, + modifier: Modifier = Modifier, + onDoneClicked : (shareHistory : Boolean) -> Unit, + sendCommand: (SelectContactsViewModel.Commands) -> Unit +) { + var shareHistory by remember { mutableStateOf(false) } + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(DismissSendInviteDialog) + }, + title = annotatedStringResource(R.string.membersInviteTitle), + text = annotatedStringResource(state.inviteContactsBody), + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareMessageHistoryDays)), + selected = !shareHistory + ) + ) { + shareHistory = false + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareNewMessagesOnly)), + selected = shareHistory, + ) + ) { + shareHistory = true + } + }, + buttons = listOf( + DialogButtonData( + text = GetString(state.inviteText), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissSendInviteDialog) + onDoneClicked(shareHistory) + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissSendInviteDialog) + } + ) + ) + ) } @Preview From 38a476f3d869a3f01f625b29898d960bcaa128e2 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 13 Nov 2025 09:19:37 +0800 Subject: [PATCH 05/17] Initial invite account id --- .../settings/ConversationSettingsNavHost.kt | 42 +++++++++++++++++++ .../groups/ManageGroupMembersViewModel.kt | 19 ++++++--- .../groups/compose/InviteAccountIdScreen.kt | 41 ++++++++++++++++++ 3 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 65660a85af..dcb2d2becf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -31,6 +31,8 @@ import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.SelectContactsViewModel import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen +import org.thoughtcrime.securesms.groups.compose.InviteAccountId +import org.thoughtcrime.securesms.groups.compose.InviteAccountIdScreen import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen import org.thoughtcrime.securesms.media.MediaOverviewScreen import org.thoughtcrime.securesms.media.MediaOverviewViewModel @@ -94,6 +96,18 @@ sealed interface ConversationSettingsDestination: Parcelable { data class RouteInviteToCommunity( val communityUrl: String ): ConversationSettingsDestination + + @Serializable + @Parcelize + data class RouteInviteAccountIdToGroup private constructor( + private val address: String, + val excludingAccountIDs: List + ): ConversationSettingsDestination { + constructor(groupAddress: Address.Group, excludingAccountIDs: List) + : this(groupAddress.address, excludingAccountIDs) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } } @SuppressLint("RestrictedApi") @@ -256,6 +270,34 @@ fun ConversationSettingsNavHost( ) } + // Invite contacts using Account ID + horizontalSlideComposable { backStackEntry -> +// val data: RouteInviteToGroup = backStackEntry.toRoute() +// +// // grab a hold of manage group's VM +// val parentEntry = remember(backStackEntry) { +// navController.getBackStackEntry( +// RouteManageMembers(data.groupAddress) +// ) +// } +// val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry) + + InviteAccountIdScreen() + +// InviteContactsScreen( +// viewModel = viewModel, +// onDoneClicked = { shareHistory -> +// //send invites from the manage group screen +// manageGroupMembersViewModel.onSendInviteClicked(viewModel.currentSelected) +// handleBack() +// }, +// onBack = dropUnlessResumed { +// handleBack() +// }, +// banner = {} +// ) + } + // Disappearing Messages horizontalSlideComposable { val viewModel: DisappearingMessagesViewModel = 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 f8486ed18b..6e47fe27fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -77,14 +77,12 @@ class ManageGroupMembersViewModel @AssistedInject constructor( OptionsItem( name = context.getString(R.string.membersInvite), icon = R.drawable.ic_user_round_plus, - onClick = ::navigateInviteContacts + onClick = ::navigateToInviteContacts ), OptionsItem( name = context.getString(R.string.accountIdOrOnsInvite), icon = R.drawable.ic_user_round_search, - onClick = { - // TODO: Add navigation - } + onClick = ::navigateToInviteAccountId ) ) } @@ -122,7 +120,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( _uiState.update { it.copy(isSearchFocused = isFocused) } } - private fun navigateInviteContacts() { + private fun navigateToInviteContacts() { viewModelScope.launch { navigator.navigate( ConversationSettingsDestination.RouteInviteToGroup( @@ -133,6 +131,17 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } + private fun navigateToInviteAccountId(){ + viewModelScope.launch { + navigator.navigate( + ConversationSettingsDestination.RouteInviteAccountIdToGroup( + groupAddress, + excludingAccountIDsFromContactSelection.toList() + ) + ) + } + } + fun onSendInviteClicked(contacts: Set
) { _uiState.update { it.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt new file mode 100644 index 0000000000..1e1deb1ace --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.components.BackAppBar + +@Composable +fun InviteAccountIdScreen() { + InviteAccountId() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InviteAccountId() { + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { + BackAppBar( + title = stringResource(id = R.string.membersInviteTitle), + onBack = { }, + ) + } + ) { paddings -> + Column( + modifier = Modifier + .padding(paddings) + .consumeWindowInsets(paddings), + ) { + + } + } +} From c8e20de734f72b792336aac35dfd3c38f7926707 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 13 Nov 2025 16:15:52 +0800 Subject: [PATCH 06/17] Reuse NewMessage component with invite member --- .../settings/ConversationSettingsNavHost.kt | 74 ++++++--- .../groups/ManageGroupMembersViewModel.kt | 4 +- .../groups/compose/InviteAccountIdScreen.kt | 151 ++++++++++++++++-- .../StartConversationSheet.kt | 4 +- .../startconversation/newmessage/Callbacks.kt | 8 + .../newmessage/NewMessage.kt | 42 +++-- .../newmessage/NewMessageViewModel.kt | 67 ++++++-- 7 files changed, 277 insertions(+), 73 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 dcb2d2becf..c5961fa0a7 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 @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms.conversation.v2.settings +import android.R.attr.data import android.annotation.SuppressLint import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -23,6 +26,7 @@ import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 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 @@ -31,13 +35,15 @@ import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.SelectContactsViewModel import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen -import org.thoughtcrime.securesms.groups.compose.InviteAccountId import org.thoughtcrime.securesms.groups.compose.InviteAccountIdScreen import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel +import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.media.MediaOverviewScreen import org.thoughtcrime.securesms.media.MediaOverviewViewModel 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.horizontalSlideComposable @@ -230,7 +236,7 @@ fun ConversationSettingsNavHost( viewModel = viewModel, onDoneClicked = { shareHistory -> //send invites from the manage group screen - manageGroupMembersViewModel.onSendInviteClicked(viewModel.currentSelected) + manageGroupMembersViewModel.onSendInviteClicked(viewModel.currentSelected, shareHistory) handleBack() }, onBack = dropUnlessResumed { @@ -272,30 +278,46 @@ fun ConversationSettingsNavHost( // Invite contacts using Account ID horizontalSlideComposable { backStackEntry -> -// val data: RouteInviteToGroup = backStackEntry.toRoute() -// -// // grab a hold of manage group's VM -// val parentEntry = remember(backStackEntry) { -// navController.getBackStackEntry( -// RouteManageMembers(data.groupAddress) -// ) -// } -// val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry) - - InviteAccountIdScreen() - -// InviteContactsScreen( -// viewModel = viewModel, -// onDoneClicked = { shareHistory -> -// //send invites from the manage group screen -// manageGroupMembersViewModel.onSendInviteClicked(viewModel.currentSelected) -// handleBack() -// }, -// onBack = dropUnlessResumed { -// handleBack() -// }, -// banner = {} -// ) + val data: RouteInviteAccountIdToGroup = backStackEntry.toRoute() + + val viewModel = hiltViewModel() + val uiState by viewModel.state.collectAsState(State()) + + // grab a hold of manage group's VM + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry( + RouteManageMembers(data.groupAddress) + ) + } + + val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry) + + LaunchedEffect(Unit) { + viewModel.success.collect { success -> + val address = success.address + val shareHistory = success.shareHistory + manageGroupMembersViewModel.onSendInviteClicked( + setOf(address), + shareHistory + ) + } + } + + InviteAccountIdScreen( + uiState, + viewModel.qrErrors, + viewModel, + onBack = { handleBack() }, + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) }, + sendCommand = viewModel::onCommand + ) + + if (uiState.showUrlDialog) { + OpenURLAlertDialog( + url = uiState.helpUrl, + onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } + ) + } } // Disappearing Messages 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 6e47fe27fb..901a2ef0c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -142,7 +142,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onSendInviteClicked(contacts: Set
) { + fun onSendInviteClicked(contacts: Set
, shareHistory : Boolean) { _uiState.update { it.copy( ongoingAction = context.resources.getQuantityString( @@ -166,7 +166,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( groupManager.inviteMembers( groupId, contacts.map { AccountId(it.toString()) }.toList(), - shareHistory = false, + shareHistory = shareHistory, isReinvite = false, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt index 1e1deb1ace..2b9133bea9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -1,41 +1,158 @@ package org.thoughtcrime.securesms.groups.compose -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.home.startconversation.newmessage.Callbacks +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel +import org.thoughtcrime.securesms.home.startconversation.newmessage.State +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors @Composable -fun InviteAccountIdScreen() { - InviteAccountId() +internal fun InviteAccountIdScreen( + state: State, + qrErrors: Flow = emptyFlow(), + callbacks: Callbacks = object : Callbacks {}, + onBack: () -> Unit = {}, + onHelp: () -> Unit = {}, + sendCommand: (NewMessageViewModel.Commands) -> Unit = {} +) { + InviteAccountId( + state = state, + qrErrors = qrErrors, + callbacks = callbacks, + onBack = onBack, + onHelp = onHelp, + sendCommand = sendCommand + ) } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun InviteAccountId() { +private fun InviteAccountId( + state: State, + qrErrors: Flow = emptyFlow(), + callbacks: Callbacks = object : Callbacks {}, + onBack: () -> Unit = {}, + onHelp: () -> Unit = {}, + sendCommand: (NewMessageViewModel.Commands) -> Unit = {} +) { Scaffold( contentWindowInsets = WindowInsets.safeDrawing, - topBar = { - BackAppBar( - title = stringResource(id = R.string.membersInviteTitle), - onBack = { }, - ) - } ) { paddings -> - Column( - modifier = Modifier - .padding(paddings) - .consumeWindowInsets(paddings), + Box( + modifier = Modifier.padding( + top = paddings.calculateTopPadding(), + bottom = paddings.calculateBottomPadding() + ) ) { - + NewMessage( + state = state, + qrErrors = qrErrors, + callbacks = callbacks, + onBack = { onBack() }, + onClose = { onBack() }, + onHelp = { onHelp() } + ) } } + + if (state.showInviteDialog) { + ShowInviteContactsDialog( + onDoneClicked = {}, + onDismissDialog = {callbacks.onDismissInviteDialog()}, + onToggleShareHistory = callbacks::onToggleShareHistory + ) + } } + +@Composable +fun ShowInviteContactsDialog( + modifier: Modifier = Modifier, + shareHistory: Boolean = false, + onDoneClicked: (shareHistory: Boolean) -> Unit, + onDismissDialog: () -> Unit, + onToggleShareHistory : (Boolean) -> Unit +) { + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + onDismissDialog() + }, + title = annotatedStringResource(R.string.membersInviteTitle), + text = annotatedStringResource(R.string.membersInviteShareDescription), // TODO: String from crowdin + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareMessageHistoryDays)), + selected = !shareHistory + ) + ) { + onToggleShareHistory(false) + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareNewMessagesOnly)), + selected = shareHistory, + ) + ) { + onToggleShareHistory(true) + } + }, + buttons = listOf( + DialogButtonData( + text = GetString( + LocalResources.current.getQuantityString( + R.plurals.membersInviteSend, + 1, + 1 + ) + ), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + onDismissDialog() + onDoneClicked(shareHistory) + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + onDismissDialog() + } + ) + ) + ) +} + +//@Preview +//@Composable +//fun PreviewInviteAccountId() { +// InviteAccountIdScreen() +//} \ No newline at end of file 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 9ce71dfba7..c8475d0390 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 @@ -155,8 +155,6 @@ 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 { @@ -182,7 +180,7 @@ fun StartConversationNavHost( ) if (uiState.showUrlDialog) { OpenURLAlertDialog( - url = helpUrl, + url = uiState.helpUrl, onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt index a70ebfd709..719df2fd7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt @@ -4,4 +4,12 @@ internal interface Callbacks { fun onChange(value: String) {} fun onContinue() {} fun onScanQrCode(value: String) {} + + fun onClearQrCode() {} + + fun onShowInviteDialog() {} + + fun onDismissInviteDialog(){} + + fun onToggleShareHistory(share : Boolean){} } 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 8981f6c99c..35c7d15fac 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 @@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -53,20 +54,38 @@ private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan) internal fun NewMessage( state: State, qrErrors: Flow = emptyFlow(), - callbacks: Callbacks = object: Callbacks {}, + callbacks: Callbacks = object : Callbacks {}, onClose: () -> Unit = {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, + isInvite: Boolean = false ) { val pagerState = rememberPagerState { TITLES.size } - Column(modifier = Modifier.background( - LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small - )) { + LaunchedEffect(state.validIdFromQr) { + if (state.validIdFromQr.isNotBlank()) { + if (isInvite) { + // auto-run the normal flow (validation etc.) + callbacks.onContinue() + } else { + // switch back to the 1st tab and proceed with invite flow + pagerState.animateScrollToPage(0) + } + + callbacks.onClearQrCode() + } + } + + Column( + modifier = Modifier.background( + LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + ) + ) { // `messageNew` is now a plurals string so get the singular version val context = LocalContext.current - val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) + val newMessageTitleTxt: String = + context.resources.getQuantityString(R.plurals.messageNew, 1, 1) BackAppBar( title = newMessageTitleTxt, @@ -78,7 +97,7 @@ internal fun NewMessage( SessionTabRow(pagerState, TITLES) HorizontalPager(pagerState) { when (TITLES[it]) { - R.string.accountIdEnter -> EnterAccountId(state, callbacks, onHelp) + R.string.accountIdEnter -> EnterAccountId(state, callbacks, onHelp, isInvite) R.string.qrScan -> QRScannerScreen(qrErrors, onScan = callbacks::onScanQrCode) } } @@ -89,7 +108,8 @@ internal fun NewMessage( private fun EnterAccountId( state: State, callbacks: Callbacks, - onHelp: () -> Unit = {} + onHelp: () -> Unit = {}, + isInvite: Boolean = false ) { Surface(color = LocalColors.current.backgroundSecondary) { Column( @@ -129,7 +149,9 @@ private fun EnterAccountId( ) } - Spacer(Modifier.weight(1f).heightIn(min = LocalDimensions.current.smallSpacing)) + Spacer(Modifier + .weight(1f) + .heightIn(min = LocalDimensions.current.smallSpacing)) AccentOutlineButton( modifier = Modifier @@ -140,7 +162,7 @@ private fun EnterAccountId( .qaTag(R.string.next), enabled = state.isNextButtonEnabled, disabledColor = LocalColors.current.textSecondary, - onClick = callbacks::onContinue + onClick = if(isInvite) callbacks::onShowInviteDialog else callbacks::onContinue ) { LoadingArcOr(state.loading) { Text(stringResource(R.string.next)) 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 d76cdcf457..0a6165624e 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 @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.home.startconversation.newmessage import android.app.Application -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.phrase.Phrase @@ -21,10 +20,8 @@ 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 -import org.thoughtcrime.securesms.preferences.SettingsViewModel import org.thoughtcrime.securesms.ui.GetString import java.net.IDN import javax.inject.Inject @@ -33,7 +30,7 @@ import javax.inject.Inject class NewMessageViewModel @Inject constructor( private val application: Application, private val configFactory: ConfigFactoryProtocol, -): ViewModel(), Callbacks { +) : ViewModel(), Callbacks { private val _state = MutableStateFlow(State()) val state = _state.asStateFlow() @@ -41,7 +38,10 @@ class NewMessageViewModel @Inject constructor( private val _success = MutableSharedFlow() val success get() = _success - private val _qrErrors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val _qrErrors = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) val qrErrors = _qrErrors.asSharedFlow() private var loadOnsJob: Job? = null @@ -52,7 +52,13 @@ class NewMessageViewModel @Inject constructor( override fun onChange(value: String) { loadOnsJob?.cancel() loadOnsJob = null - _state.update { it.copy(newMessageIdOrOns = value, isTextErrorColor = false, loading = false) } + _state.update { + it.copy( + newMessageIdOrOns = value, + isTextErrorColor = false, + loading = false + ) + } } override fun onContinue() { @@ -98,13 +104,31 @@ class NewMessageViewModel @Inject constructor( isPrefixRequired = false ) && PublicKeyValidation.hasValidPrefix(value) ) { - onPublicKey(value) + onChange(value) + _state.update { it.copy(validIdFromQr = value) } } else { _qrErrors.tryEmit(application.getString(R.string.qrNotAccountId)) + _state.update { it.copy(validIdFromQr = "") } } } } + override fun onClearQrCode() { + _state.update {it.copy(validIdFromQr = "") } + } + + override fun onShowInviteDialog() { + _state.update { it.copy(showInviteDialog = true) } + } + + override fun onDismissInviteDialog() { + _state.update { it.copy(showInviteDialog = false) } + } + + override fun onToggleShareHistory(share: Boolean) { + _state.update { it.copy(shareMessageHistory = share) } + } + private fun resolveONS(ons: String) { if (loadOnsJob?.isActive == true) return @@ -114,7 +138,7 @@ class NewMessageViewModel @Inject constructor( loadOnsJob = viewModelScope.launch { try { val publicKey = withTimeout(30_000L, { - SnodeAPI.getAccountID(ons) + SnodeAPI.getAccountID(ons) }) onPublicKey(publicKey) } catch (e: Exception) { @@ -125,7 +149,12 @@ class NewMessageViewModel @Inject constructor( } private fun onError(e: Exception) { - _state.update { it.copy(loading = false, isTextErrorColor = true, error = GetString(e) { it.toMessage() }) } + _state.update { + it.copy( + loading = false, + isTextErrorColor = true, + error = GetString(e) { it.toMessage() }) + } } private fun onPublicKey(publicKey: String) { @@ -133,7 +162,7 @@ class NewMessageViewModel @Inject constructor( val address = publicKey.toAddress() if (address is Address.Standard) { - viewModelScope.launch { _success.emit(Success(address)) } + viewModelScope.launch { _success.emit(Success(address, state.value.shareMessageHistory)) } } } @@ -141,7 +170,13 @@ class NewMessageViewModel @Inject constructor( if (PublicKeyValidation.hasValidPrefix(publicKey)) { onPublicKey(publicKey) } else { - _state.update { it.copy(isTextErrorColor = true, error = GetString(R.string.accountIdErrorInvalid), loading = false) } + _state.update { + it.copy( + isTextErrorColor = true, + error = GetString(R.string.accountIdErrorInvalid), + loading = false + ) + } } } @@ -179,12 +214,14 @@ data class State( val isTextErrorColor: Boolean = false, val error: GetString? = null, val loading: Boolean = false, - val showUrlDialog : Boolean = false + val showUrlDialog: Boolean = false, + val helpUrl : String = "https://getsession.org/account-ids", + val showInviteDialog : Boolean = false, + val validIdFromQr: String = "", + val shareMessageHistory : Boolean = false ) { val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() } - - -data class Success(val address: Address.Standard) \ No newline at end of file +data class Success(val address: Address.Standard, val shareHistory: Boolean = false) \ No newline at end of file From 7866a51dbc95f141799b1f3c45125b9d69f126cc Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 14 Nov 2025 07:19:42 +0800 Subject: [PATCH 07/17] Conditions and cleanups for invite mode --- .../v2/settings/ConversationSettingsNavHost.kt | 3 +-- .../groups/compose/InviteAccountIdScreen.kt | 12 ++---------- .../startconversation/newmessage/NewMessage.kt | 17 ++++++++--------- 3 files changed, 11 insertions(+), 21 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 c5961fa0a7..57eed30d9c 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 @@ -308,8 +308,7 @@ fun ConversationSettingsNavHost( viewModel.qrErrors, viewModel, onBack = { handleBack() }, - onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) }, - sendCommand = viewModel::onCommand + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } ) if (uiState.showUrlDialog) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt index 2b9133bea9..d886ff6ec0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -7,20 +7,14 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import network.loki.messenger.R import org.thoughtcrime.securesms.home.startconversation.newmessage.Callbacks import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage -import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonData @@ -37,7 +31,6 @@ internal fun InviteAccountIdScreen( callbacks: Callbacks = object : Callbacks {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, - sendCommand: (NewMessageViewModel.Commands) -> Unit = {} ) { InviteAccountId( state = state, @@ -45,7 +38,6 @@ internal fun InviteAccountIdScreen( callbacks = callbacks, onBack = onBack, onHelp = onHelp, - sendCommand = sendCommand ) } @@ -57,7 +49,6 @@ private fun InviteAccountId( callbacks: Callbacks = object : Callbacks {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, - sendCommand: (NewMessageViewModel.Commands) -> Unit = {} ) { Scaffold( contentWindowInsets = WindowInsets.safeDrawing, @@ -74,7 +65,8 @@ private fun InviteAccountId( callbacks = callbacks, onBack = { onBack() }, onClose = { onBack() }, - onHelp = { onHelp() } + onHelp = { onHelp() }, + isInvite = true ) } } 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 35c7d15fac..0c69937b4a 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 @@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -78,20 +79,19 @@ internal fun NewMessage( Column( modifier = Modifier.background( - LocalColors.current.backgroundSecondary, + if (isInvite) LocalColors.current.background else LocalColors.current.backgroundSecondary, shape = MaterialTheme.shapes.small ) ) { // `messageNew` is now a plurals string so get the singular version - val context = LocalContext.current - val newMessageTitleTxt: String = - context.resources.getQuantityString(R.plurals.messageNew, 1, 1) + val newMessageTitleTxt: String = if(isInvite) LocalResources.current.getString(R.string.membersInviteTitle) else + LocalResources.current.getQuantityString(R.plurals.messageNew, 1, 1) BackAppBar( title = newMessageTitleTxt, backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container onBack = onBack, - actions = { AppBarCloseIcon(onClose = onClose) }, + actions = { if(!isInvite) AppBarCloseIcon(onClose = onClose) }, windowInsets = WindowInsets(0, 0, 0, 0), // Insets handled by the dialog ) SessionTabRow(pagerState, TITLES) @@ -111,7 +111,7 @@ private fun EnterAccountId( onHelp: () -> Unit = {}, isInvite: Boolean = false ) { - Surface(color = LocalColors.current.backgroundSecondary) { + Surface(color = if (isInvite) LocalColors.current.background else LocalColors.current.backgroundSecondary) { Column( modifier = Modifier .fillMaxSize() @@ -137,7 +137,7 @@ private fun EnterAccountId( Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) BorderlessButtonWithIcon( - text = stringResource(R.string.messageNewDescriptionMobile), + text = stringResource(if(isInvite) R.string.inviteNewMemberGroupLink else R.string.messageNewDescriptionMobile), modifier = Modifier .qaTag(R.string.AccessibilityId_messageNewDescriptionMobile) .padding(horizontal = LocalDimensions.current.mediumSpacing) @@ -165,12 +165,11 @@ private fun EnterAccountId( onClick = if(isInvite) callbacks::onShowInviteDialog else callbacks::onContinue ) { LoadingArcOr(state.loading) { - Text(stringResource(R.string.next)) + Text(stringResource(if(isInvite) R.string.membersInviteTitle else R.string.next)) } } } } - } @Preview From c610a701dd359592220b7c808b57cabd538e21ff Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 14 Nov 2025 08:00:14 +0800 Subject: [PATCH 08/17] Cleanup to state and invite members --- .../settings/ConversationSettingsNavHost.kt | 7 +--- .../groups/ManageGroupMembersViewModel.kt | 31 ++++++++++---- .../groups/compose/InviteAccountIdScreen.kt | 40 ++++++++++++------- .../compose/ManageGroupMembersScreen.kt | 7 ++-- .../startconversation/newmessage/Callbacks.kt | 6 --- .../newmessage/NewMessageViewModel.kt | 18 +-------- 6 files changed, 56 insertions(+), 53 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 57eed30d9c..96c3e3aa1f 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 @@ -294,12 +294,7 @@ fun ConversationSettingsNavHost( LaunchedEffect(Unit) { viewModel.success.collect { success -> - val address = success.address - val shareHistory = success.shareHistory - manageGroupMembersViewModel.onSendInviteClicked( - setOf(address), - shareHistory - ) + manageGroupMembersViewModel.onCommand(ManageGroupMembersViewModel.Commands.ShowInviteMemberDialog) } } 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 901a2ef0c9..7a8be57c4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -21,7 +21,6 @@ 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 import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupInviteException import org.session.libsession.messaging.groups.GroupManagerV2 @@ -309,15 +308,23 @@ class ManageGroupMembersViewModel @AssistedInject constructor( _uiState.update { it.copy(ongoingAction = null) } } - private fun toggleRemoveDialog(visible : Boolean){ + private fun toggleRemoveMembersDialog(visible : Boolean){ showRemoveMembersDialog.value = visible } + private fun toggleInviteMembersDialog(visible : Boolean){ + _uiState.update { it.copy(isInviteMemberDialogVisible = visible) } + } + fun onCommand(command: Commands) { when (command) { - is Commands.ShowRemoveDialog -> toggleRemoveDialog(true) + is Commands.ShowRemoveMembersDialog -> toggleRemoveMembersDialog(true) + + is Commands.DismissRemoveMembersDialog -> toggleRemoveMembersDialog(false) - is Commands.DismissRemoveDialog -> toggleRemoveDialog(false) + is Commands.DismissInviteMemberDialog -> toggleInviteMembersDialog(false) + + is Commands.ShowInviteMemberDialog -> toggleInviteMembersDialog(true) is Commands.RemoveMembers -> onRemoveContact(command.removeMessages) @@ -338,6 +345,8 @@ class ManageGroupMembersViewModel @AssistedInject constructor( is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + + is Commands.SendInvites -> onSendInviteClicked(command.address, command.shareHistory) } } @@ -410,7 +419,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ), buttonLabel = GetString(context.getString(R.string.remove)), isDanger = true, - onClick = { onCommand(Commands.ShowRemoveDialog) } + onClick = { onCommand(Commands.ShowRemoveMembersDialog) } ) ) @@ -429,6 +438,8 @@ class ManageGroupMembersViewModel @AssistedInject constructor( val error: String? = null, val ongoingAction: String? = null, + val isInviteMemberDialogVisible : Boolean = false, + // search UI state: val searchQuery: String = "", val isSearchFocused: Boolean = false, @@ -462,8 +473,8 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ) sealed interface Commands { - data object ShowRemoveDialog : Commands - data object DismissRemoveDialog : Commands + data object ShowRemoveMembersDialog : Commands + data object DismissRemoveMembersDialog : Commands data object DismissError : Commands @@ -475,6 +486,12 @@ class ManageGroupMembersViewModel @AssistedInject constructor( data object ClearSelection : Commands + data object ShowInviteMemberDialog : Commands + + data object DismissInviteMemberDialog : Commands + + data class SendInvites(val address : Set
, val shareHistory: Boolean) : Commands + data class RemoveSearchState(val clearSelection : Boolean) : Commands data class SearchQueryChange(val query : String) : Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt index d886ff6ec0..d9013c9320 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -7,12 +7,19 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.Callbacks import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage import org.thoughtcrime.securesms.home.startconversation.newmessage.State @@ -31,6 +38,8 @@ internal fun InviteAccountIdScreen( callbacks: Callbacks = object : Callbacks {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, + sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit = {}, + inviteDialogVisible: Boolean = false ) { InviteAccountId( state = state, @@ -38,6 +47,8 @@ internal fun InviteAccountIdScreen( callbacks = callbacks, onBack = onBack, onHelp = onHelp, + sendCommand = sendCommand, + inviteDialogVisible = inviteDialogVisible ) } @@ -49,6 +60,8 @@ private fun InviteAccountId( callbacks: Callbacks = object : Callbacks {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, + sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit = {}, + inviteDialogVisible: Boolean ) { Scaffold( contentWindowInsets = WindowInsets.safeDrawing, @@ -71,28 +84,27 @@ private fun InviteAccountId( } } - if (state.showInviteDialog) { + if (inviteDialogVisible) { ShowInviteContactsDialog( - onDoneClicked = {}, - onDismissDialog = {callbacks.onDismissInviteDialog()}, - onToggleShareHistory = callbacks::onToggleShareHistory + address = state.newMessageIdOrOns.toAddress(), + sendCommand = sendCommand, ) } } @Composable fun ShowInviteContactsDialog( + address : Address, modifier: Modifier = Modifier, - shareHistory: Boolean = false, - onDoneClicked: (shareHistory: Boolean) -> Unit, - onDismissDialog: () -> Unit, - onToggleShareHistory : (Boolean) -> Unit + sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit, ) { + var shareHistory by remember { mutableStateOf(false) } + AlertDialog( modifier = modifier, onDismissRequest = { // hide dialog - onDismissDialog() + sendCommand(ManageGroupMembersViewModel.Commands.DismissInviteMemberDialog) }, title = annotatedStringResource(R.string.membersInviteTitle), text = annotatedStringResource(R.string.membersInviteShareDescription), // TODO: String from crowdin @@ -104,7 +116,7 @@ fun ShowInviteContactsDialog( selected = !shareHistory ) ) { - onToggleShareHistory(false) + shareHistory = false } DialogTitledRadioButton( @@ -114,7 +126,7 @@ fun ShowInviteContactsDialog( selected = shareHistory, ) ) { - onToggleShareHistory(true) + shareHistory = true } }, buttons = listOf( @@ -129,14 +141,14 @@ fun ShowInviteContactsDialog( color = LocalColors.current.danger, dismissOnClick = false, onClick = { - onDismissDialog() - onDoneClicked(shareHistory) + sendCommand(ManageGroupMembersViewModel.Commands.DismissInviteMemberDialog) + sendCommand(ManageGroupMembersViewModel.Commands.SendInvites(setOf(address), shareHistory)) } ), DialogButtonData( text = GetString(stringResource(R.string.cancel)), onClick = { - onDismissDialog() + sendCommand(ManageGroupMembersViewModel.Commands.DismissInviteMemberDialog) } ) ) 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..59b20391ab 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 @@ -327,7 +326,7 @@ fun ShowRemoveMembersDialog( modifier = modifier, onDismissRequest = { // hide dialog - sendCommand(DismissRemoveDialog) + sendCommand(DismissRemoveMembersDialog) }, title = annotatedStringResource(R.string.remove), text = annotatedStringResource(state.removeMemberBody), @@ -358,14 +357,14 @@ fun ShowRemoveMembersDialog( color = LocalColors.current.danger, dismissOnClick = false, onClick = { - sendCommand(DismissRemoveDialog) + sendCommand(DismissRemoveMembersDialog) sendCommand(RemoveMembers(deleteMessages)) } ), DialogButtonData( text = GetString(stringResource(R.string.cancel)), onClick = { - sendCommand(DismissRemoveDialog) + sendCommand(DismissRemoveMembersDialog) } ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt index 719df2fd7b..723f897f19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt @@ -6,10 +6,4 @@ internal interface Callbacks { fun onScanQrCode(value: String) {} fun onClearQrCode() {} - - fun onShowInviteDialog() {} - - fun onDismissInviteDialog(){} - - fun onToggleShareHistory(share : Boolean){} } 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 0a6165624e..04b565bd63 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 @@ -117,18 +117,6 @@ class NewMessageViewModel @Inject constructor( _state.update {it.copy(validIdFromQr = "") } } - override fun onShowInviteDialog() { - _state.update { it.copy(showInviteDialog = true) } - } - - override fun onDismissInviteDialog() { - _state.update { it.copy(showInviteDialog = false) } - } - - override fun onToggleShareHistory(share: Boolean) { - _state.update { it.copy(shareMessageHistory = share) } - } - private fun resolveONS(ons: String) { if (loadOnsJob?.isActive == true) return @@ -162,7 +150,7 @@ class NewMessageViewModel @Inject constructor( val address = publicKey.toAddress() if (address is Address.Standard) { - viewModelScope.launch { _success.emit(Success(address, state.value.shareMessageHistory)) } + viewModelScope.launch { _success.emit(Success(address)) } } } @@ -216,12 +204,10 @@ data class State( val loading: Boolean = false, val showUrlDialog: Boolean = false, val helpUrl : String = "https://getsession.org/account-ids", - val showInviteDialog : Boolean = false, val validIdFromQr: String = "", - val shareMessageHistory : Boolean = false ) { val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() } -data class Success(val address: Address.Standard, val shareHistory: Boolean = false) \ No newline at end of file +data class Success(val address: Address.Standard) \ No newline at end of file From c94608301d4edaa018448f950bab05d46ace762f Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 14 Nov 2025 08:26:41 +0800 Subject: [PATCH 09/17] Cleanup --- .../thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 7a8be57c4c..e78dee08d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -21,6 +21,7 @@ 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 import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupInviteException import org.session.libsession.messaging.groups.GroupManagerV2 From 4d57a08474a05b86cfdf10bd8cd65a03dfa68838 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 14 Nov 2025 09:00:36 +0800 Subject: [PATCH 10/17] Fixed params, more cleanups --- .../settings/ConversationSettingsNavHost.kt | 8 ++- .../groups/compose/InviteAccountIdScreen.kt | 54 ++++++++++++++----- .../compose/ManageGroupMembersScreen.kt | 2 +- .../newmessage/NewMessage.kt | 6 +-- 4 files changed, 53 insertions(+), 17 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 f601af611c..9b77a81f43 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 @@ -305,7 +305,13 @@ fun ConversationSettingsNavHost( viewModel.qrErrors, viewModel, onBack = { handleBack() }, - onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) }, + onSendInvite = { address, shareHistory -> + manageGroupMembersViewModel.onCommand(ManageGroupMembersViewModel.Commands.SendInvites(address, shareHistory)) + handleBack() + }, + sendCommand = manageGroupMembersViewModel::onCommand, + inviteDialogVisible = manageGroupMembersViewModel.uiState.collectAsState().value.isInviteMemberDialogVisible ) if (uiState.showUrlDialog) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt index d9013c9320..22219d509c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -14,14 +14,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import network.loki.messenger.R import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.* import org.thoughtcrime.securesms.home.startconversation.newmessage.Callbacks import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonData @@ -30,6 +34,8 @@ import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors +import kotlin.Boolean +import kotlin.String @Composable internal fun InviteAccountIdScreen( @@ -38,7 +44,8 @@ internal fun InviteAccountIdScreen( callbacks: Callbacks = object : Callbacks {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, - sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit = {}, + sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit, + onSendInvite: (address: Set
, shareHistory: Boolean) -> Unit, inviteDialogVisible: Boolean = false ) { InviteAccountId( @@ -47,6 +54,7 @@ internal fun InviteAccountIdScreen( callbacks = callbacks, onBack = onBack, onHelp = onHelp, + onSendInvite = onSendInvite, sendCommand = sendCommand, inviteDialogVisible = inviteDialogVisible ) @@ -61,6 +69,7 @@ private fun InviteAccountId( onBack: () -> Unit = {}, onHelp: () -> Unit = {}, sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit = {}, + onSendInvite: (Set
, Boolean) -> Unit, inviteDialogVisible: Boolean ) { Scaffold( @@ -79,7 +88,7 @@ private fun InviteAccountId( onBack = { onBack() }, onClose = { onBack() }, onHelp = { onHelp() }, - isInvite = true + isInvite = true, ) } } @@ -88,14 +97,16 @@ private fun InviteAccountId( ShowInviteContactsDialog( address = state.newMessageIdOrOns.toAddress(), sendCommand = sendCommand, + onSendInvite = onSendInvite ) } } @Composable fun ShowInviteContactsDialog( - address : Address, + address: Address, modifier: Modifier = Modifier, + onSendInvite: (Set
, Boolean) -> Unit, sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit, ) { var shareHistory by remember { mutableStateOf(false) } @@ -104,7 +115,7 @@ fun ShowInviteContactsDialog( modifier = modifier, onDismissRequest = { // hide dialog - sendCommand(ManageGroupMembersViewModel.Commands.DismissInviteMemberDialog) + sendCommand(DismissInviteMemberDialog) }, title = annotatedStringResource(R.string.membersInviteTitle), text = annotatedStringResource(R.string.membersInviteShareDescription), // TODO: String from crowdin @@ -141,22 +152,41 @@ fun ShowInviteContactsDialog( color = LocalColors.current.danger, dismissOnClick = false, onClick = { - sendCommand(ManageGroupMembersViewModel.Commands.DismissInviteMemberDialog) - sendCommand(ManageGroupMembersViewModel.Commands.SendInvites(setOf(address), shareHistory)) + sendCommand(DismissInviteMemberDialog) + onSendInvite( + setOf(address), + shareHistory + ) } ), DialogButtonData( text = GetString(stringResource(R.string.cancel)), onClick = { - sendCommand(ManageGroupMembersViewModel.Commands.DismissInviteMemberDialog) + sendCommand(DismissInviteMemberDialog) } ) ) ) } -//@Preview -//@Composable -//fun PreviewInviteAccountId() { -// InviteAccountIdScreen() -//} \ No newline at end of file +@Preview +@Composable +fun PreviewInviteAccountId() { + InviteAccountIdScreen( + State( + newMessageIdOrOns = "", + isTextErrorColor = false, + error = null, + loading = false, + showUrlDialog = false, + helpUrl = "https://getsession.org/account-ids", + validIdFromQr = "", + ), + emptyFlow(), + onBack = { }, + onHelp = { }, + onSendInvite = {_, _ ->}, + sendCommand = { }, + inviteDialogVisible = false + ) +} \ No newline at end of file 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 59b20391ab..6c8b49ea59 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 @@ -252,7 +252,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 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 0c69937b4a..55082cee7a 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 @@ -59,7 +59,7 @@ internal fun NewMessage( onClose: () -> Unit = {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, - isInvite: Boolean = false + isInvite: Boolean = false, ) { val pagerState = rememberPagerState { TITLES.size } @@ -109,7 +109,7 @@ private fun EnterAccountId( state: State, callbacks: Callbacks, onHelp: () -> Unit = {}, - isInvite: Boolean = false + isInvite: Boolean = false, ) { Surface(color = if (isInvite) LocalColors.current.background else LocalColors.current.backgroundSecondary) { Column( @@ -162,7 +162,7 @@ private fun EnterAccountId( .qaTag(R.string.next), enabled = state.isNextButtonEnabled, disabledColor = LocalColors.current.textSecondary, - onClick = if(isInvite) callbacks::onShowInviteDialog else callbacks::onContinue + onClick = callbacks::onContinue ) { LoadingArcOr(state.loading) { Text(stringResource(if(isInvite) R.string.membersInviteTitle else R.string.next)) From 1b57870e79f2fa55c3cf15cc33cc773c49a0fe9f Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 14 Nov 2025 09:19:02 +0800 Subject: [PATCH 11/17] Flipped logic --- .../home/startconversation/newmessage/NewMessage.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 55082cee7a..414b1c55a2 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 @@ -66,11 +66,11 @@ internal fun NewMessage( LaunchedEffect(state.validIdFromQr) { if (state.validIdFromQr.isNotBlank()) { if (isInvite) { - // auto-run the normal flow (validation etc.) - callbacks.onContinue() - } else { // switch back to the 1st tab and proceed with invite flow pagerState.animateScrollToPage(0) + } else { + // auto-run the normal flow () + callbacks.onContinue() } callbacks.onClearQrCode() From 48997eef4869779f5702dd96f14bab9ad6f9a6c4 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 14 Nov 2025 15:33:28 +0800 Subject: [PATCH 12/17] Updated InviteScreen --- .../settings/ConversationSettingsNavHost.kt | 13 +- .../groups/InviteMembersViewModel.kt | 216 ++++++++++++++++++ .../groups/SelectContactsViewModel.kt | 172 +------------- .../groups/compose/InviteContactsScreen.kt | 62 ++--- 4 files changed, 261 insertions(+), 202 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 9b77a81f43..eed2ae92cd 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 @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversation.v2.settings -import android.R.attr.data import android.annotation.SuppressLint import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -26,13 +25,12 @@ import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 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.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel -import org.thoughtcrime.securesms.groups.SelectContactsViewModel +import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen import org.thoughtcrime.securesms.groups.compose.InviteAccountIdScreen @@ -220,8 +218,9 @@ fun ConversationSettingsNavHost( val data: RouteInviteToGroup = backStackEntry.toRoute() val viewModel = - hiltViewModel { factory -> + hiltViewModel { factory -> factory.create( + groupAddress = data.groupAddress, excludingAccountIDs = data.excludingAccountIDs.map(Address::fromSerialized).toSet() ) } @@ -244,14 +243,14 @@ fun ConversationSettingsNavHost( onBack = dropUnlessResumed { handleBack() }, - banner = {} + forCommunity = false ) } // Invite Contacts to community horizontalSlideComposable { backStackEntry -> val viewModel = - hiltViewModel { factory -> + hiltViewModel { factory -> factory.create() } @@ -271,10 +270,12 @@ fun ConversationSettingsNavHost( // clear selected contacts viewModel.clearSelection() + handleBack() }, onBack = dropUnlessResumed { handleBack() }, + forCommunity = true ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt new file mode 100644 index 0000000000..2f7941e28a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt @@ -0,0 +1,216 @@ +package org.thoughtcrime.securesms.groups + +import android.R.attr.data +import android.content.Context +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.util.AvatarUtils + +@HiltViewModel(assistedFactory = InviteMembersViewModel.Factory::class) +class InviteMembersViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group?, + @Assisted private val excludingAccountIDs: Set
, + @param:ApplicationContext private val context: Context, + configFactory: ConfigFactory, + avatarUtils: AvatarUtils, + proStatusManager: ProStatusManager, + recipientRepository: RecipientRepository, +) : SelectContactsViewModel( + configFactory = configFactory, + excludingAccountIDs = excludingAccountIDs, + contactFiltering = SelectContactsViewModel.Factory.defaultFiltering, + avatarUtils = avatarUtils, + proStatusManager = proStatusManager, + recipientRepository = recipientRepository, + context = context +) { + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + private val footerCollapsed = MutableStateFlow(false) + private val showInviteContactsDialog = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine(selectedContacts, footerCollapsed) { selected, isCollapsed -> + buildFooterState(selected, isCollapsed) + }.collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + viewModelScope.launch { + combine(selectedContacts, showInviteContactsDialog) { selected, showDialog -> + buildInviteContactsDialogState(showDialog, selected) + }.collect { state -> + _uiState.update { it.copy(inviteContactsDialog = state) } + } + } + } + + 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.contactSelected, count, count) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title + ) + } + + private fun buildInviteContactsDialogState( + visible: Boolean, + selected: Set + ): InviteContactsDialogState { + val count = selected.size + val firstMember = selected.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> Phrase.from(context, R.string.membersInviteShareDescription) + .put(NAME_KEY, firstMember?.name) + .format() + + 2 -> { + val secondMember = selected.elementAtOrNull(1)?.name + Phrase.from(context, R.string.membersInviteShareDescriptionTwo) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.membersInviteShareDescriptionMultiple) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .format() + } + + val inviteText = + context.resources.getQuantityString(R.plurals.membersInviteSend, count, count) + + return InviteContactsDialogState( + visible = visible, + inviteContactsBody = body, + inviteText = inviteText + ) + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + fun toggleInviteContactsDialog(visible: Boolean) { + showInviteContactsDialog.value = visible + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun sendCommand(command: Commands) { + when (command) { + is Commands.ToggleFooter -> toggleFooter() + + is Commands.CloseFooter, + Commands.ClearSelection -> clearSelection() + + is Commands.ContactItemClick -> onContactItemClicked(command.address) + + is Commands.DismissSendInviteDialog -> toggleInviteContactsDialog(false) + + is Commands.ShowSendInviteDialog -> toggleInviteContactsDialog(true) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + } + } + + sealed interface Commands { + data object ToggleFooter : Commands + + data object CloseFooter : Commands + + data object ShowSendInviteDialog : Commands + + data object DismissSendInviteDialog : Commands + + data object ClearSelection : Commands + + data class ContactItemClick(val address: Address) : Commands + + data class SearchFocusChange(val focus: Boolean) : Commands + + data class SearchQueryChange(val query: String) : Commands + + data class RemoveSearchState(val clearSelection: Boolean) : Commands + } + + + data class UiState( + val isSearchFocused: Boolean = false, + val ongoingAction: String? = null, + + val inviteContactsDialog: InviteContactsDialogState = InviteContactsDialogState(), + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class InviteContactsDialogState( + val visible: Boolean = false, + val inviteContactsBody: CharSequence = "", + val inviteText: String = "" + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle: GetString = GetString("") + ) + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group? = null, + excludingAccountIDs: Set
= emptySet(), + ): InviteMembersViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 702c30c159..54cfec926f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.groups import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -23,20 +22,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import network.loki.messenger.R import org.session.libsession.utilities.Address -import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.search.searchName import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils @@ -60,6 +53,7 @@ open class SelectContactsViewModel @AssistedInject constructor( // Input: The search query private val mutableSearchQuery = MutableStateFlow("") + // Output: The search query val searchQuery: StateFlow get() = mutableSearchQuery @@ -77,34 +71,13 @@ open class SelectContactsViewModel @AssistedInject constructor( .map { it.isNotEmpty() } .stateIn(viewModelScope, SharingStarted.Eagerly, false) - private val _uiState = MutableStateFlow(UiState()) - val uiState: StateFlow = _uiState + // Output: to be used by VMs extending this base VM + val selectedContacts: StateFlow> = mutableSelectedContacts - // Output + // Output : snapshot helper val currentSelected: Set
get() = mutableSelectedContacts.value.map { it.address }.toSet() - private val footerCollapsed = MutableStateFlow(false) - private val showInviteContactsDialog = MutableStateFlow(false) - - init { - viewModelScope.launch { - combine(mutableSelectedContacts, footerCollapsed) { selected, isCollapsed -> - buildFooterState(selected, isCollapsed) - }.collect { footer -> - _uiState.update { it.copy(footer = footer) } - } - } - - viewModelScope.launch { - combine(showInviteContactsDialog, mutableSelectedContacts) { showDialog, selected -> - buildInviteContactsDialogState(showDialog, selected) - }.collect { state -> - _uiState.update { it.copy(inviteContactsDialog = state) } - } - } - } - @OptIn(ExperimentalCoroutinesApi::class) private fun observeContacts() = (configFactory.configUpdateNotifications as Flow) .debounce(100L) @@ -185,143 +158,6 @@ open class SelectContactsViewModel @AssistedInject constructor( mutableSelectedContacts.value = emptySet() } - fun toggleFooter() { - footerCollapsed.update { !it } - } - - fun onSearchFocusChanged(isFocused :Boolean){ - _uiState.update { it.copy(isSearchFocused = isFocused) } - } - - fun onDismissResend() { - _uiState.update { it.copy(ongoingAction = null) } - } - - fun removeSearchState(clearSelection : Boolean){ - onSearchFocusChanged(false) - onSearchQueryChanged("") - - if(clearSelection){ - clearSelection() - } - } - - 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.contactSelected, count, count) - ) - - return CollapsibleFooterState( - visible = visible, - collapsed = if (!visible) false else isCollapsed, - footerActionTitle = title - ) - } - - private fun buildInviteContactsDialogState( - visible: Boolean, - selected: Set - ): InviteContactsDialogState { - val count = selected.size - val firstMember = selected.firstOrNull() - - val body: CharSequence = when (count) { - 1 -> Phrase.from(context, R.string.membersInviteShareDescription) - .put(NAME_KEY, firstMember?.name) - .format() - - 2 -> { - val secondMember = selected.elementAtOrNull(1)?.name - Phrase.from(context, R.string.membersInviteShareDescriptionTwo) - .put(NAME_KEY, firstMember?.name) - .put(OTHER_NAME_KEY, secondMember) - .format() - } - - 0 -> "" - else -> Phrase.from(context, R.string.membersInviteShareDescriptionMultiple) - .put(NAME_KEY, firstMember?.name) - .put(COUNT_KEY, count - 1) - .format() - } - - val inviteText = - context.resources.getQuantityString(R.plurals.membersInviteSend, count, count) - - return InviteContactsDialogState( - visible = visible, - inviteContactsBody = body, - inviteText = inviteText - ) - } - - fun toggleInviteContactsDialog(visible : Boolean){ - showInviteContactsDialog.value = visible - } - - fun sendCommand(command: Commands) { - when (command) { - is Commands.ClearSelection -> clearSelection() - is Commands.ToggleFooter -> toggleFooter() - is Commands.CloseFooter -> clearSelection() - is Commands.DismissResend -> onDismissResend() - is Commands.ShowSendInviteDialog -> toggleInviteContactsDialog(true) - is Commands.DismissSendInviteDialog -> toggleInviteContactsDialog(false) - is Commands.ContactItemClick -> onContactItemClicked(command.address) - is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) - is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) - is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) - } - } - - data class UiState( - val isSearchFocused: Boolean = false, - val ongoingAction: String? = null, - - val inviteContactsDialog: InviteContactsDialogState = InviteContactsDialogState(), - val footer: CollapsibleFooterState = CollapsibleFooterState() - ) - - data class InviteContactsDialogState( - val visible : Boolean = false, - val inviteContactsBody : CharSequence = "", - val inviteText : String = "" - ) - - data class CollapsibleFooterState( - val visible: Boolean = false, - val collapsed: Boolean = false, - val footerActionTitle : GetString = GetString("") - ) - - sealed interface Commands { - data object ClearSelection : Commands - - data object ToggleFooter : Commands - - data object CloseFooter : Commands - - data object DismissResend : Commands - - data object ShowSendInviteDialog : Commands - - data object DismissSendInviteDialog : Commands - - data class ContactItemClick(val address: Address) : Commands - - data class RemoveSearchState(val clearSelection: Boolean) : Commands - - data class SearchQueryChange(val query: String) : Commands - - data class SearchFocusChange(val focus: Boolean) : Commands - } - @AssistedFactory interface Factory { fun create( 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 0f8beb8324..29277660d1 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 @@ -35,8 +35,15 @@ import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem -import org.thoughtcrime.securesms.groups.SelectContactsViewModel -import org.thoughtcrime.securesms.groups.SelectContactsViewModel.Commands.* +import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.CloseFooter +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ContactItemClick +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.DismissSendInviteDialog +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.RemoveSearchState +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.SearchFocusChange +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.SearchQueryChange +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ShowSendInviteDialog +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ToggleFooter import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData @@ -57,13 +64,13 @@ import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement - @Composable fun InviteContactsScreen( - viewModel: SelectContactsViewModel, - onDoneClicked: (shareHistory : Boolean) -> Unit, + viewModel: InviteMembersViewModel, + onDoneClicked: (shareHistory: Boolean) -> Unit, onBack: () -> Unit, - banner: @Composable () -> Unit = {} + banner: @Composable () -> Unit = {}, + forCommunity : Boolean = false, ) { InviteContacts( contacts = viewModel.contacts.collectAsState().value, @@ -72,7 +79,8 @@ fun InviteContactsScreen( onDoneClicked = onDoneClicked, onBack = onBack, banner = banner, - sendCommand = viewModel::sendCommand + sendCommand = viewModel::sendCommand, + forCommunity = forCommunity ) } @@ -80,13 +88,13 @@ fun InviteContactsScreen( @Composable fun InviteContacts( contacts: List, - uiState: SelectContactsViewModel.UiState, + uiState: InviteMembersViewModel.UiState, searchQuery: String, - onDoneClicked: (shareHistory : Boolean) -> Unit, + onDoneClicked: (shareHistory: Boolean) -> Unit, onBack: () -> Unit, banner: @Composable () -> Unit = {}, - sendCommand: (command: SelectContactsViewModel.Commands) -> Unit - + sendCommand: (command: InviteMembersViewModel.Commands) -> Unit, + forCommunity: Boolean = false ) { val trayItems = listOf( @@ -94,18 +102,16 @@ fun InviteContacts( label = GetString(LocalResources.current.getString(R.string.membersInvite)), buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)), isDanger = false, - onClick = { sendCommand(ShowSendInviteDialog) } + onClick = { + if (forCommunity) onDoneClicked(false) // Community does not need the dialog + else sendCommand(ShowSendInviteDialog) + } ) ) val handleBack: () -> Unit = { when { - uiState.isSearchFocused -> sendCommand( - SelectContactsViewModel.Commands.RemoveSearchState( - false - ) - ) - + uiState.isSearchFocused -> sendCommand(RemoveSearchState(false)) else -> onBack() } } @@ -205,10 +211,10 @@ fun InviteContacts( @Composable fun ShowInviteContactsDialog( - state: SelectContactsViewModel.InviteContactsDialogState, + state: InviteMembersViewModel.InviteContactsDialogState, modifier: Modifier = Modifier, - onDoneClicked : (shareHistory : Boolean) -> Unit, - sendCommand: (SelectContactsViewModel.Commands) -> Unit + onDoneClicked: (shareHistory: Boolean) -> Unit, + sendCommand: (InviteMembersViewModel.Commands) -> Unit ) { var shareHistory by remember { mutableStateOf(false) } @@ -289,14 +295,14 @@ private fun PreviewSelectContacts() { onBack = {}, banner = {}, sendCommand = {}, - uiState = SelectContactsViewModel.UiState( - footer = SelectContactsViewModel.CollapsibleFooterState( + uiState = InviteMembersViewModel.UiState( + footer = InviteMembersViewModel.CollapsibleFooterState( collapsed = false, visible = true, footerActionTitle = GetString("1 Contact Selected") ) ), - searchQuery = "" + searchQuery = "", ) } } @@ -313,8 +319,8 @@ private fun PreviewSelectEmptyContacts() { onBack = {}, banner = {}, sendCommand = {}, - uiState = SelectContactsViewModel.UiState( - footer = SelectContactsViewModel.CollapsibleFooterState( + uiState = InviteMembersViewModel.UiState( + footer = InviteMembersViewModel.CollapsibleFooterState( collapsed = true, visible = false, footerActionTitle = GetString("") @@ -337,8 +343,8 @@ private fun PreviewSelectEmptyContactsWithSearch() { onBack = {}, banner = {}, sendCommand = {}, - uiState = SelectContactsViewModel.UiState( - footer = SelectContactsViewModel.CollapsibleFooterState( + uiState = InviteMembersViewModel.UiState( + footer = InviteMembersViewModel.CollapsibleFooterState( collapsed = true, visible = false, footerActionTitle = GetString("") From ae34406aa4225d4575825da2accad1d82b59e77f Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 14 Nov 2025 17:48:58 +0800 Subject: [PATCH 13/17] Added invite members viewmodel, made dialog reusable, cleanups --- .../settings/ConversationSettingsNavHost.kt | 44 ++++-- .../groups/InviteMembersViewModel.kt | 26 +++- .../groups/ManageGroupMembersViewModel.kt | 14 -- .../groups/SelectContactsViewModel.kt | 6 + .../securesms/groups/compose/Components.kt | 82 ++++++++++- .../groups/compose/InviteAccountIdScreen.kt | 128 +++--------------- .../groups/compose/InviteContactsScreen.kt | 74 +--------- 7 files changed, 155 insertions(+), 219 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 eed2ae92cd..a31729a9d9 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 @@ -283,8 +283,16 @@ fun ConversationSettingsNavHost( horizontalSlideComposable { backStackEntry -> val data: RouteInviteAccountIdToGroup = backStackEntry.toRoute() - val viewModel = hiltViewModel() - val uiState by viewModel.state.collectAsState(State()) + val viewModel = + hiltViewModel { factory -> + factory.create( + groupAddress = data.groupAddress, + excludingAccountIDs = data.excludingAccountIDs.map(Address::fromSerialized).toSet() + ) + } + + val newMessageViewModel = hiltViewModel() + val uiState by newMessageViewModel.state.collectAsState(State()) // grab a hold of manage group's VM val parentEntry = remember(backStackEntry) { @@ -296,29 +304,37 @@ fun ConversationSettingsNavHost( val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry) LaunchedEffect(Unit) { - viewModel.success.collect { success -> - manageGroupMembersViewModel.onCommand(ManageGroupMembersViewModel.Commands.ShowInviteMemberDialog) + newMessageViewModel.success.collect { success -> + viewModel.sendCommand( + InviteMembersViewModel.Commands.HandleAccountId( + address = success.address + ) + ) } } InviteAccountIdScreen( - uiState, - viewModel.qrErrors, - viewModel, + viewModel = viewModel, + state = uiState, + qrErrors = newMessageViewModel.qrErrors, + callbacks = newMessageViewModel, onBack = { handleBack() }, - onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) }, - onSendInvite = { address, shareHistory -> - manageGroupMembersViewModel.onCommand(ManageGroupMembersViewModel.Commands.SendInvites(address, shareHistory)) + onHelp = { newMessageViewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) }, + onSendInvite = {shareHistory -> + manageGroupMembersViewModel.onCommand( + ManageGroupMembersViewModel.Commands.SendInvites( + address = viewModel.currentSelected, + shareHistory = shareHistory + ) + ) handleBack() - }, - sendCommand = manageGroupMembersViewModel::onCommand, - inviteDialogVisible = manageGroupMembersViewModel.uiState.collectAsState().value.isInviteMemberDialogVisible + } ) if (uiState.showUrlDialog) { OpenURLAlertDialog( url = uiState.helpUrl, - onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } + onDismissRequest = { newMessageViewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt index 2f7941e28a..184a5f0223 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.groups -import android.R.attr.data import android.content.Context import androidx.lifecycle.viewModelScope import com.squareup.phrase.Phrase @@ -88,16 +87,22 @@ class InviteMembersViewModel @AssistedInject constructor( private fun buildInviteContactsDialogState( visible: Boolean, - selected: Set + selected: Set, ): InviteContactsDialogState { val count = selected.size val firstMember = selected.firstOrNull() val body: CharSequence = when (count) { - 1 -> Phrase.from(context, R.string.membersInviteShareDescription) - .put(NAME_KEY, firstMember?.name) - .format() - + 1 -> { + if (firstMember != null && firstMember.name.isNotEmpty()) { + Phrase.from(context, R.string.membersInviteShareDescription) + .put(NAME_KEY, firstMember?.name) + .format() + } else { + // TODO: Need to add String in Crowdin + context.getString(R.string.membersInviteShareDescription) + } + } 2 -> { val secondMember = selected.elementAtOrNull(1)?.name Phrase.from(context, R.string.membersInviteShareDescriptionTwo) @@ -153,6 +158,11 @@ class InviteMembersViewModel @AssistedInject constructor( is Commands.ContactItemClick -> onContactItemClicked(command.address) + is Commands.HandleAccountId -> { + setManuallySelectedAddress(command.address) + toggleInviteContactsDialog(true) + } + is Commands.DismissSendInviteDialog -> toggleInviteContactsDialog(false) is Commands.ShowSendInviteDialog -> toggleInviteContactsDialog(true) @@ -176,6 +186,8 @@ class InviteMembersViewModel @AssistedInject constructor( data object ClearSelection : Commands + data class HandleAccountId(val address : Address) : Commands + data class ContactItemClick(val address: Address) : Commands data class SearchFocusChange(val focus: Boolean) : Commands @@ -197,7 +209,7 @@ class InviteMembersViewModel @AssistedInject constructor( data class InviteContactsDialogState( val visible: Boolean = false, val inviteContactsBody: CharSequence = "", - val inviteText: String = "" + val inviteText: String = "", ) data class CollapsibleFooterState( 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 e78dee08d2..006a61dcd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -313,20 +313,12 @@ class ManageGroupMembersViewModel @AssistedInject constructor( showRemoveMembersDialog.value = visible } - private fun toggleInviteMembersDialog(visible : Boolean){ - _uiState.update { it.copy(isInviteMemberDialogVisible = visible) } - } - fun onCommand(command: Commands) { when (command) { is Commands.ShowRemoveMembersDialog -> toggleRemoveMembersDialog(true) is Commands.DismissRemoveMembersDialog -> toggleRemoveMembersDialog(false) - is Commands.DismissInviteMemberDialog -> toggleInviteMembersDialog(false) - - is Commands.ShowInviteMemberDialog -> toggleInviteMembersDialog(true) - is Commands.RemoveMembers -> onRemoveContact(command.removeMessages) is Commands.ClearSelection, @@ -439,8 +431,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( val error: String? = null, val ongoingAction: String? = null, - val isInviteMemberDialogVisible : Boolean = false, - // search UI state: val searchQuery: String = "", val isSearchFocused: Boolean = false, @@ -487,10 +477,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( data object ClearSelection : Commands - data object ShowInviteMemberDialog : Commands - - data object DismissInviteMemberDialog : Commands - data class SendInvites(val address : Set
, val shareHistory: Boolean) : Commands data class RemoveSearchState(val clearSelection : Boolean) : Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 54cfec926f..c53fa6423c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -132,6 +132,12 @@ open class SelectContactsViewModel @AssistedInject constructor( mutableManuallyAddedContacts.value = accountIDs } + // Used when getting results from a QR or AccountId input field + fun setManuallySelectedAddress(address : Address){ + val selectedItem = SelectedContact(address, "") + mutableSelectedContacts.value = setOf(selectedItem) + } + fun onSearchQueryChanged(query: String) { mutableSearchQuery.value = query } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index c510d9d4b3..b0eda823ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -12,18 +12,30 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Text import androidx.compose.runtime.Composable +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.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.Avatar +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.RadioButtonIndicator +import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -56,7 +68,7 @@ fun GroupMinimumVersionBanner(modifier: Modifier = Modifier) { } @Composable -fun MemberItem( +fun MemberItem( address: Address, title: String, avatarUIData: AvatarUIData, @@ -69,7 +81,7 @@ fun MemberItem( content: @Composable RowScope.() -> Unit = {}, ) { var itemModifier = modifier - if(onClick != null){ + if (onClick != null) { itemModifier = itemModifier.clickable(onClick = { onClick(address) }) } @@ -86,7 +98,9 @@ fun MemberItem( Avatar( size = LocalDimensions.current.iconLarge, data = avatarUIData, - badge = if (showAsAdmin) { AvatarBadge.Admin } else AvatarBadge.None + badge = if (showAsAdmin) { + AvatarBadge.Admin + } else AvatarBadge.None ) Column( @@ -133,11 +147,11 @@ fun RadioMemberItem( title = title, subtitle = subtitle, subtitleColor = subtitleColor, - onClick = if(enabled) onClick else null, + onClick = if (enabled) onClick else null, showAsAdmin = showAsAdmin, showProBadge = showProBadge, modifier = modifier - ){ + ) { RadioButtonIndicator( selected = selected, enabled = enabled @@ -167,6 +181,64 @@ fun LazyListScope.multiSelectMemberList( } } +@Composable +fun ShowInviteContactsDialog( + state: InviteMembersViewModel.InviteContactsDialogState, + modifier: Modifier = Modifier, + onInviteClicked: (Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var shareHistory by remember { mutableStateOf(false) } + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + onDismiss() + }, + title = annotatedStringResource(R.string.membersInviteTitle), + text = annotatedStringResource(state.inviteContactsBody), + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareMessageHistoryDays)), + selected = !shareHistory + ) + ) { + shareHistory = false + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareNewMessagesOnly)), + selected = shareHistory, + ) + ) { + shareHistory = true + } + }, + buttons = listOf( + DialogButtonData( + text = GetString(state.inviteText), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + onDismiss() + onInviteClicked(shareHistory) + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + onDismiss() + } + ) + ) + ) +} + @Preview @Composable fun PreviewMemberList() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt index 22219d509c..01970db1df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -7,56 +7,35 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow -import network.loki.messenger.R -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Address.Companion.toAddress -import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel -import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.* +import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.Callbacks import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage -import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State -import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonData -import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton -import org.thoughtcrime.securesms.ui.components.annotatedStringResource -import org.thoughtcrime.securesms.ui.theme.LocalColors -import kotlin.Boolean -import kotlin.String @Composable internal fun InviteAccountIdScreen( - state: State, + viewModel: InviteMembersViewModel, + state: State, // new message state qrErrors: Flow = emptyFlow(), callbacks: Callbacks = object : Callbacks {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, - sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit, - onSendInvite: (address: Set
, shareHistory: Boolean) -> Unit, - inviteDialogVisible: Boolean = false + onSendInvite: (shareHistory: Boolean) -> Unit ) { InviteAccountId( state = state, + inviteState = viewModel.uiState.collectAsState().value.inviteContactsDialog, qrErrors = qrErrors, callbacks = callbacks, onBack = onBack, onHelp = onHelp, onSendInvite = onSendInvite, - sendCommand = sendCommand, - inviteDialogVisible = inviteDialogVisible + onDismissInviteDialog = { viewModel.sendCommand(InviteMembersViewModel.Commands.DismissSendInviteDialog) } ) } @@ -64,13 +43,13 @@ internal fun InviteAccountIdScreen( @Composable private fun InviteAccountId( state: State, + inviteState: InviteMembersViewModel.InviteContactsDialogState, qrErrors: Flow = emptyFlow(), callbacks: Callbacks = object : Callbacks {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, - sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit = {}, - onSendInvite: (Set
, Boolean) -> Unit, - inviteDialogVisible: Boolean + onSendInvite: (Boolean) -> Unit, + onDismissInviteDialog: () -> Unit ) { Scaffold( contentWindowInsets = WindowInsets.safeDrawing, @@ -93,87 +72,20 @@ private fun InviteAccountId( } } - if (inviteDialogVisible) { + if (inviteState.visible) { ShowInviteContactsDialog( - address = state.newMessageIdOrOns.toAddress(), - sendCommand = sendCommand, - onSendInvite = onSendInvite + state = inviteState, + onInviteClicked = onSendInvite, + onDismiss = onDismissInviteDialog ) } } -@Composable -fun ShowInviteContactsDialog( - address: Address, - modifier: Modifier = Modifier, - onSendInvite: (Set
, Boolean) -> Unit, - sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit, -) { - var shareHistory by remember { mutableStateOf(false) } - - AlertDialog( - modifier = modifier, - onDismissRequest = { - // hide dialog - sendCommand(DismissInviteMemberDialog) - }, - title = annotatedStringResource(R.string.membersInviteTitle), - text = annotatedStringResource(R.string.membersInviteShareDescription), // TODO: String from crowdin - content = { - DialogTitledRadioButton( - option = RadioOption( - value = Unit, - title = GetString(LocalResources.current.getString(R.string.membersInviteShareMessageHistoryDays)), - selected = !shareHistory - ) - ) { - shareHistory = false - } - - DialogTitledRadioButton( - option = RadioOption( - value = Unit, - title = GetString(LocalResources.current.getString(R.string.membersInviteShareNewMessagesOnly)), - selected = shareHistory, - ) - ) { - shareHistory = true - } - }, - buttons = listOf( - DialogButtonData( - text = GetString( - LocalResources.current.getQuantityString( - R.plurals.membersInviteSend, - 1, - 1 - ) - ), - color = LocalColors.current.danger, - dismissOnClick = false, - onClick = { - sendCommand(DismissInviteMemberDialog) - onSendInvite( - setOf(address), - shareHistory - ) - } - ), - DialogButtonData( - text = GetString(stringResource(R.string.cancel)), - onClick = { - sendCommand(DismissInviteMemberDialog) - } - ) - ) - ) -} - @Preview @Composable fun PreviewInviteAccountId() { - InviteAccountIdScreen( - State( + InviteAccountId( + state = State( newMessageIdOrOns = "", isTextErrorColor = false, error = null, @@ -182,11 +94,11 @@ fun PreviewInviteAccountId() { helpUrl = "https://getsession.org/account-ids", validIdFromQr = "", ), - emptyFlow(), onBack = { }, onHelp = { }, - onSendInvite = {_, _ ->}, - sendCommand = { }, - inviteDialogVisible = false + onSendInvite = {_ -> }, + inviteState = InviteMembersViewModel.InviteContactsDialogState(), + qrErrors = emptyFlow(), + onDismissInviteDialog = {}, ) } \ 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 29277660d1..727069a117 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 @@ -22,10 +22,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.Modifier import androidx.compose.ui.platform.LocalResources @@ -38,23 +34,17 @@ import org.thoughtcrime.securesms.groups.ContactItem import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.CloseFooter import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ContactItemClick -import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.DismissSendInviteDialog import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.RemoveSearchState import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.SearchFocusChange import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.SearchQueryChange import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ShowSendInviteDialog import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ToggleFooter -import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData -import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.SearchBarWithClose import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton -import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -70,7 +60,7 @@ fun InviteContactsScreen( onDoneClicked: (shareHistory: Boolean) -> Unit, onBack: () -> Unit, banner: @Composable () -> Unit = {}, - forCommunity : Boolean = false, + forCommunity: Boolean = false, ) { InviteContacts( contacts = viewModel.contacts.collectAsState().value, @@ -203,70 +193,12 @@ fun InviteContacts( if (uiState.inviteContactsDialog.visible) { ShowInviteContactsDialog( state = uiState.inviteContactsDialog, - onDoneClicked = onDoneClicked, - sendCommand = sendCommand + onInviteClicked = onDoneClicked, + onDismiss = { } ) } } -@Composable -fun ShowInviteContactsDialog( - state: InviteMembersViewModel.InviteContactsDialogState, - modifier: Modifier = Modifier, - onDoneClicked: (shareHistory: Boolean) -> Unit, - sendCommand: (InviteMembersViewModel.Commands) -> Unit -) { - var shareHistory by remember { mutableStateOf(false) } - - AlertDialog( - modifier = modifier, - onDismissRequest = { - // hide dialog - sendCommand(DismissSendInviteDialog) - }, - title = annotatedStringResource(R.string.membersInviteTitle), - text = annotatedStringResource(state.inviteContactsBody), - content = { - DialogTitledRadioButton( - option = RadioOption( - value = Unit, - title = GetString(LocalResources.current.getString(R.string.membersInviteShareMessageHistoryDays)), - selected = !shareHistory - ) - ) { - shareHistory = false - } - - DialogTitledRadioButton( - option = RadioOption( - value = Unit, - title = GetString(LocalResources.current.getString(R.string.membersInviteShareNewMessagesOnly)), - selected = shareHistory, - ) - ) { - shareHistory = true - } - }, - buttons = listOf( - DialogButtonData( - text = GetString(state.inviteText), - color = LocalColors.current.danger, - dismissOnClick = false, - onClick = { - sendCommand(DismissSendInviteDialog) - onDoneClicked(shareHistory) - } - ), - DialogButtonData( - text = GetString(stringResource(R.string.cancel)), - onClick = { - sendCommand(DismissSendInviteDialog) - } - ) - ) - ) -} - @Preview @Composable private fun PreviewSelectContacts() { From 1a106553af97186da82b5739052def09be2bf6f4 Mon Sep 17 00:00:00 2001 From: jbsession Date: Fri, 14 Nov 2025 17:49:48 +0800 Subject: [PATCH 14/17] Updated dialog name --- .../org/thoughtcrime/securesms/groups/compose/Components.kt | 2 +- .../securesms/groups/compose/InviteAccountIdScreen.kt | 2 +- .../securesms/groups/compose/InviteContactsScreen.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index b0eda823ee..09428ee146 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -182,7 +182,7 @@ fun LazyListScope.multiSelectMemberList( } @Composable -fun ShowInviteContactsDialog( +fun InviteMembersDialog( state: InviteMembersViewModel.InviteContactsDialogState, modifier: Modifier = Modifier, onInviteClicked: (Boolean) -> Unit, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt index 01970db1df..99758f0dc5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -73,7 +73,7 @@ private fun InviteAccountId( } if (inviteState.visible) { - ShowInviteContactsDialog( + InviteMembersDialog( state = inviteState, onInviteClicked = onSendInvite, onDismiss = onDismissInviteDialog 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 727069a117..a857eb4065 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 @@ -191,7 +191,7 @@ fun InviteContacts( } if (uiState.inviteContactsDialog.visible) { - ShowInviteContactsDialog( + InviteMembersDialog( state = uiState.inviteContactsDialog, onInviteClicked = onDoneClicked, onDismiss = { } From 46dee23995348c5d301b0312d6a9c9881c490310 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 17 Nov 2025 07:33:42 +0800 Subject: [PATCH 15/17] Updated InviteAccountId declaration --- .../securesms/groups/compose/InviteAccountIdScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt index 99758f0dc5..87e35b47fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import kotlinx.coroutines.flow.Flow @@ -27,9 +28,11 @@ internal fun InviteAccountIdScreen( onHelp: () -> Unit = {}, onSendInvite: (shareHistory: Boolean) -> Unit ) { + val uiState by viewModel.uiState.collectAsState() + InviteAccountId( state = state, - inviteState = viewModel.uiState.collectAsState().value.inviteContactsDialog, + inviteState = uiState.inviteContactsDialog, qrErrors = qrErrors, callbacks = callbacks, onBack = onBack, From e136271dce674060e27daf349ce7d6179bb03ddf Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 17 Nov 2025 07:50:04 +0800 Subject: [PATCH 16/17] Updated help dialog state --- .../settings/ConversationSettingsNavHost.kt | 14 +++++-------- .../groups/compose/InviteAccountIdScreen.kt | 20 +++++++++++++++---- .../StartConversationSheet.kt | 4 ++-- .../newmessage/NewMessageViewModel.kt | 8 ++++---- 4 files changed, 27 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 a31729a9d9..3366c335fe 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 @@ -320,7 +320,10 @@ fun ConversationSettingsNavHost( callbacks = newMessageViewModel, onBack = { handleBack() }, onHelp = { newMessageViewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) }, - onSendInvite = {shareHistory -> + onDismissHelpDialog = { + newMessageViewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) + }, + onSendInvite = { shareHistory -> manageGroupMembersViewModel.onCommand( ManageGroupMembersViewModel.Commands.SendInvites( address = viewModel.currentSelected, @@ -328,15 +331,8 @@ fun ConversationSettingsNavHost( ) ) handleBack() - } + }, ) - - if (uiState.showUrlDialog) { - OpenURLAlertDialog( - url = uiState.helpUrl, - onDismissRequest = { newMessageViewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } - ) - } } // Disappearing Messages diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt index 87e35b47fe..e5b937f475 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -16,7 +16,9 @@ import kotlinx.coroutines.flow.emptyFlow import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.Callbacks import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog @Composable internal fun InviteAccountIdScreen( @@ -26,7 +28,8 @@ internal fun InviteAccountIdScreen( callbacks: Callbacks = object : Callbacks {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, - onSendInvite: (shareHistory: Boolean) -> Unit + onDismissHelpDialog: () -> Unit, + onSendInvite: (shareHistory: Boolean) -> Unit, ) { val uiState by viewModel.uiState.collectAsState() @@ -37,6 +40,7 @@ internal fun InviteAccountIdScreen( callbacks = callbacks, onBack = onBack, onHelp = onHelp, + onDismissHelpDialog = onDismissHelpDialog, onSendInvite = onSendInvite, onDismissInviteDialog = { viewModel.sendCommand(InviteMembersViewModel.Commands.DismissSendInviteDialog) } ) @@ -51,6 +55,7 @@ private fun InviteAccountId( callbacks: Callbacks = object : Callbacks {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, + onDismissHelpDialog: () -> Unit, onSendInvite: (Boolean) -> Unit, onDismissInviteDialog: () -> Unit ) { @@ -82,6 +87,13 @@ private fun InviteAccountId( onDismiss = onDismissInviteDialog ) } + + if(!state.showUrlDialog.isNullOrEmpty()) { + OpenURLAlertDialog( + url = state.showUrlDialog, + onDismissRequest = { onDismissHelpDialog() } + ) + } } @Preview @@ -93,15 +105,15 @@ fun PreviewInviteAccountId() { isTextErrorColor = false, error = null, loading = false, - showUrlDialog = false, - helpUrl = "https://getsession.org/account-ids", + showUrlDialog = null, validIdFromQr = "", ), onBack = { }, onHelp = { }, - onSendInvite = {_ -> }, + onSendInvite = { _ -> }, inviteState = InviteMembersViewModel.InviteContactsDialogState(), qrErrors = emptyFlow(), onDismissInviteDialog = {}, + onDismissHelpDialog = {}, ) } \ No newline at end of file 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 1c66db657d..0a07730db8 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 @@ -177,9 +177,9 @@ fun StartConversationNavHost( onClose = onClose, onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } ) - if (uiState.showUrlDialog) { + if (uiState.showUrlDialog != null) { OpenURLAlertDialog( - url = uiState.helpUrl, + url = uiState.showUrlDialog!!, 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 04b565bd63..9e9b3b017c 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 @@ -31,6 +31,7 @@ class NewMessageViewModel @Inject constructor( private val application: Application, private val configFactory: ConfigFactoryProtocol, ) : ViewModel(), Callbacks { + private val HELP_URL : String = "https://getsession.org/account-ids" private val _state = MutableStateFlow(State()) val state = _state.asStateFlow() @@ -178,13 +179,13 @@ class NewMessageViewModel @Inject constructor( fun onCommand(commands: Commands) { when (commands) { is Commands.ShowUrlDialog -> { - _state.update { it.copy(showUrlDialog = true) } + _state.update { it.copy(showUrlDialog = HELP_URL) } } is Commands.DismissUrlDialog -> { _state.update { it.copy( - showUrlDialog = false + showUrlDialog = null ) } } @@ -202,8 +203,7 @@ data class State( val isTextErrorColor: Boolean = false, val error: GetString? = null, val loading: Boolean = false, - val showUrlDialog: Boolean = false, - val helpUrl : String = "https://getsession.org/account-ids", + val showUrlDialog: String? = null, val validIdFromQr: String = "", ) { val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() From 14f4f7e9b4ee7606a791cabfdec37d79c50c6494 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 4 Dec 2025 08:31:22 +0800 Subject: [PATCH 17/17] Merge with ses-4753/invite-members --- .../messaging/groups/GroupInviteException.kt | 6 +- .../messaging/groups/GroupManagerV2.kt | 4 + .../settings/ConversationSettingsDialogs.kt | 2 + .../settings/ConversationSettingsNavHost.kt | 72 +++++ .../settings/ConversationSettingsViewModel.kt | 63 +++- .../groups/BaseGroupMembersViewModel.kt | 88 +++++- .../securesms/groups/GroupManagerV2Impl.kt | 86 ++++- .../groups/ManageGroupAdminsViewModel.kt | 295 ++++++++++++++++++ .../groups/ManageGroupMembersViewModel.kt | 148 +++------ .../groups/PromoteMembersViewModel.kt | 251 +++++++++++++++ .../securesms/groups/compose/Components.kt | 41 ++- .../groups/compose/GroupMembersScreen.kt | 3 + .../groups/compose/ManageGroupAdminsScreen.kt | 291 +++++++++++++++++ .../compose/ManageGroupMembersScreen.kt | 62 +--- .../groups/compose/PromoteMembersScreen.kt | 288 +++++++++++++++++ .../thoughtcrime/securesms/ui/AlertDialog.kt | 4 +- .../thoughtcrime/securesms/ui/UINavigator.kt | 5 +- .../src/main/res/values/strings.xml | 1 + 18 files changed, 1537 insertions(+), 173 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt index d2a2c5239f..7e5cd7a7d1 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt @@ -43,7 +43,7 @@ class GroupInviteException( if (second != null && third != null) { val errorString = - if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else + if (isPromotion) if (isReinvite) R.string.failedResendPromotionMultiple else R.string.adminPromotionFailedDescriptionMultiple else if (isReinvite) R.string.failedResendInviteMultiple else R.string.groupInviteFailedMultiple return Phrase.from(context, errorString) .put(NAME_KEY, first) @@ -51,7 +51,7 @@ class GroupInviteException( .put(GROUP_NAME_KEY, groupName) .format() } else if (second != null) { - val errorString = if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else + val errorString = if (isPromotion) if (isReinvite) R.string.failedResendPromotionTwo else R.string.adminPromotionFailedDescriptionTwo else if (isReinvite) R.string.failedResendInviteTwo else R.string.groupInviteFailedTwo return Phrase.from(context, errorString) .put(NAME_KEY, first) @@ -59,7 +59,7 @@ class GroupInviteException( .put(GROUP_NAME_KEY, groupName) .format() } else { - val errorString = if (isPromotion) R.string.adminPromotionFailedDescription else + val errorString = if (isPromotion) if (isReinvite) R.string.failedResendPromotion else R.string.adminPromotionFailedDescription else if (isReinvite) R.string.failedResendInvite else R.string.groupInviteFailedUser return Phrase.from(context, errorString) .put(NAME_KEY, first) 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 3384628c39..8fafdd9e08 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -125,6 +125,10 @@ interface GroupManagerV2 { fun getLeaveGroupConfirmationDialogData(groupId: AccountId, name: String): ConfirmDialogData? + fun getAdminLeaveGroupDialogData(groupId : AccountId, name : String) : ConfirmDialogData? + + fun isCurrentUserLastAdmin(groupId : AccountId) : Boolean + data class ConfirmDialogData( val title: String, val message: CharSequence, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index d058fb29f9..2492c99893 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -80,6 +80,8 @@ fun ConversationSettingsDialogs( buttons.add( DialogButtonData( text = GetString(dialogsState.showSimpleDialog.negativeText), + color = if (dialogsState.showSimpleDialog.negativeStyleDanger) LocalColors.current.danger + else LocalColors.current.text, qaTag = dialogsState.showSimpleDialog.negativeQaTag, onClick = dialogsState.showSimpleDialog.onNegative ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 3366c335fe..50891467e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute @@ -31,10 +32,14 @@ import org.thoughtcrime.securesms.conversation.v2.settings.notification.Notifica import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen import org.thoughtcrime.securesms.groups.compose.InviteAccountIdScreen import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen +import org.thoughtcrime.securesms.groups.compose.ManageGroupAdminsScreen +import org.thoughtcrime.securesms.groups.compose.PromoteMembersScreen import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.media.MediaOverviewScreen @@ -71,6 +76,30 @@ sealed interface ConversationSettingsDestination: Parcelable { val groupAddress: Address.Group get() = Address.Group(AccountId(address)) } + @Serializable + @Parcelize + data class RouteManageAdmins private constructor( + private val address: String, + val navigateToPromoteMembers: Boolean = false + ) : ConversationSettingsDestination { + constructor(groupAddress: Address.Group, navigateToPromoteMembers: Boolean = false) : this( + groupAddress.address, + navigateToPromoteMembers + ) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } + + @Serializable + @Parcelize + data class RoutePromoteMembers( + private val address: String + ): ConversationSettingsDestination { + constructor(groupAddress: Address.Group): this(groupAddress.address) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } + @Serializable @Parcelize data class RouteInviteToGroup private constructor( @@ -213,6 +242,23 @@ fun ConversationSettingsNavHost( ) } + // Manage group Admins + horizontalSlideComposable { backStackEntry -> + val data: RouteManageAdmins = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(data.groupAddress, navigator, data.navigateToPromoteMembers) + } + + ManageGroupAdminsScreen( + viewModel = viewModel, + onBack = dropUnlessResumed { + handleBack() + }, + ) + } + // Invite Contacts to group horizontalSlideComposable { backStackEntry -> val data: RouteInviteToGroup = backStackEntry.toRoute() @@ -335,6 +381,32 @@ fun ConversationSettingsNavHost( ) } + // Promote Members to group Admin + horizontalSlideComposable { backStackEntry -> + val data: RoutePromoteMembers = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(groupAddress = data.groupAddress) + } + + val parentEntry = remember(backStackEntry) { + navController.previousBackStackEntry ?: error("RouteManageAdmin not in backstack") + } + val manageGroupAdminsViewModel: ManageGroupAdminsViewModel = hiltViewModel(parentEntry) + + PromoteMembersScreen( + viewModel = viewModel, + onBack = dropUnlessResumed { + handleBack() + }, + onPromoteClicked = { selectedMembers -> + manageGroupAdminsViewModel.onSendPromotionsClicked(selectedMembers) + handleBack() + } + ) + } + // Disappearing Messages horizontalSlideComposable { val viewModel: DisappearingMessagesViewModel = 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 4b26d8d43b..4439797398 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 @@ -284,10 +285,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( OptionsItem( name = context.getString(R.string.manageAdmins), icon = R.drawable.ic_add_admin_custom, - qaTag = R.string.qa_conversation_settings_manage_members, + qaTag = R.string.qa_conversation_settings_manage_admins, onClick = { (address as? Address.Group)?.let { - navigateTo(ConversationSettingsDestination.RouteManageMembers(it)) + navigateTo(ConversationSettingsDestination.RouteManageAdmins(it)) } } ) @@ -311,6 +312,16 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } + private val optionAdminLeaveGroup: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.groupLeave), + icon = R.drawable.ic_log_out, + qaTag = R.string.qa_conversation_settings_leave_group, + onClick = ::confirmAdminLeaveGroup + ) + } + + // Community private val optionCopyCommunityURL: OptionsItem by lazy{ OptionsItem( @@ -580,7 +591,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( dangerOptions.addAll( listOf( optionClearMessages, - optionLeaveGroup, + optionAdminLeaveGroup, optionDeleteGroup ) ) @@ -1026,8 +1037,50 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun confirmAdminLeaveGroup(){ + val groupV2Id = (address as? Address.Group)?.accountId ?: return + val isUserLastAdmin = groupManager.isCurrentUserLastAdmin(groupV2Id) + _dialogState.update { state -> + val dialogData = groupManager.getAdminLeaveGroupDialogData( + groupV2Id, + _uiState.value.name + ) ?: return + + state.copy( + showSimpleDialog = SimpleDialogData( + title = dialogData.title, + message = dialogData.message, + positiveText = context.getString(dialogData.positiveText), + negativeText = context.getString(dialogData.negativeText), + positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, + onPositive = { + if (isUserLastAdmin){ + // Calling this to have the ManageAdminScreen in the backstack so we can + // get its VM and PromoteMembersScreen can navigate back to it after sending promotions + navigateTo( + ConversationSettingsDestination.RouteManageAdmins( + groupAddress = address, + navigateToPromoteMembers = true + ) + ) + }else{ + leaveGroup() + } + }, + positiveStyleDanger = !isUserLastAdmin, + onNegative = { + if (isUserLastAdmin) confirmLeaveGroup() + }, + negativeStyleDanger = isUserLastAdmin // red color on the right + ) + ) + } + } + private fun confirmLeaveGroup(){ val groupV2Id = (address as? Address.Group)?.accountId ?: return + _dialogState.update { state -> val dialogData = groupManager.getLeaveGroupConfirmationDialogData( groupV2Id, @@ -1040,8 +1093,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( message = dialogData.message, positiveText = context.getString(dialogData.positiveText), negativeText = context.getString(dialogData.negativeText), - positiveQaTag = dialogData.positiveQaTag?.let{ context.getString(it) }, - negativeQaTag = dialogData.negativeQaTag?.let{ context.getString(it) }, + positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, onPositive = ::leaveGroup, onNegative = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index e335a661f2..fe897f2f63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms.groups import android.content.Context +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -16,6 +19,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.allWithStatus @@ -32,6 +36,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils import java.util.EnumSet +import kotlin.coroutines.cancellation.CancellationException abstract class BaseGroupMembersViewModel( groupAddress: Address.Group, @@ -76,6 +81,11 @@ abstract class BaseGroupMembersViewModel( } }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + // Current group name (for header / text, if needed) + val groupName: StateFlow = groupInfo + .map { it?.first?.name.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + private val mutableSearchQuery = MutableStateFlow("") val searchQuery: StateFlow get() = mutableSearchQuery @@ -92,11 +102,33 @@ abstract class BaseGroupMembersViewModel( .map { list -> list.filter { !it.showAsAdmin } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + // Output : List of active members that can be promoted + val activeMembers: StateFlow> = members + .map { list -> list.filter { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val hasActiveMembers: StateFlow = + groupInfo + .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + val hasNonAdminMembers: StateFlow = groupInfo .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin } } .stateIn(viewModelScope, SharingStarted.Lazily, false) + // Output: List of only ADMINS + val adminMembers: StateFlow> = members + .map { list -> + list.filter { it.showAsAdmin } + .sortedWith( + compareBy { adminOrder(it) } + .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + .thenBy { it.accountId } + ) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + fun onSearchQueryChanged(query: String) { mutableSearchQuery.value = query } @@ -148,7 +180,8 @@ abstract class BaseGroupMembersViewModel( showProBadge = shouldShowProBadge, avatarUIData = avatarUtils.getUIDataFromAccountId(memberAccountId.hexString), clickable = !isMyself, - statusLabel = getMemberLabel(status, context, amIAdmin) + statusLabel = getMemberLabel(status, context, amIAdmin), + isSelf = isMyself ) } @@ -188,6 +221,44 @@ abstract class BaseGroupMembersViewModel( .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) .thenBy { it.accountId } ) + + fun showToast(text: String) { + Toast.makeText( + context, text, Toast.LENGTH_SHORT + ).show() + } + + /** + * Perform a group operation, such as inviting a member, removing a member. + * + * This is a helper function that encapsulates the common error handling and progress tracking. + */ + protected fun performGroupOperationCore( + showLoading: Boolean = false, + setLoading: (Boolean) -> Unit = {}, + errorMessage: ((Throwable) -> String?)? = null, + operation: suspend () -> Unit + ) { + viewModelScope.launch { + if (showLoading) setLoading(true) + + // We need to use GlobalScope here because we don't want + // any group operation to be cancelled when the view model is cleared. + @Suppress("OPT_IN_USAGE") + val task = GlobalScope.async { + operation() + } + + try { + task.await() + } catch (e: Throwable) { + val msg = errorMessage?.invoke(e) ?: context.getString(R.string.errorUnknown) + showToast(msg) + } finally { + if (showLoading) setLoading(false) + } + } + } } private fun stateOrder(status: GroupMember.Status?): Int = when (status) { @@ -209,6 +280,18 @@ private fun stateOrder(status: GroupMember.Status?): Int = when (status) { else -> 6 } +private fun adminOrder(state: GroupMemberState): Int { + if (state.isSelf) return 7 // "You" always last + return when (state.status) { + GroupMember.Status.PROMOTION_FAILED -> 1 + GroupMember.Status.PROMOTION_NOT_SENT -> 2 + GroupMember.Status.PROMOTION_UNKNOWN -> 3 + GroupMember.Status.PROMOTION_SENDING -> 4 + GroupMember.Status.PROMOTION_SENT -> 5 + else -> 6 + } +} + data class GroupMemberState( val accountId: AccountId, val avatarUIData: AvatarUIData, @@ -222,7 +305,8 @@ data class GroupMemberState( val canRemove: Boolean, val canPromote: Boolean, val clickable: Boolean, - val statusLabel: String + val statusLabel: String, + val isSelf: Boolean ) { val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index e7ed8240cc..5fc4fe2813 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -15,6 +15,7 @@ import network.loki.messenger.R import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.Bytes.Companion.toBytes import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode @@ -564,16 +565,16 @@ class GroupManagerV2Impl @Inject constructor( // Wait and gather all the promote message sending result into a result map val promotedByMemberIDs = promotionDeferred - .mapValues { - runCatching { it.value.await() }.isSuccess + .mapValues { (_, deferred) -> + runCatching { deferred.await() } } // Update each member's status configFactory.withMutableGroupConfigs(group) { configs -> promotedByMemberIDs.asSequence() - .mapNotNull { (member, success) -> + .mapNotNull { (member, result) -> configs.groupMembers.get(member.hexString)?.apply { - if (success) { + if (result.isSuccess) { setPromotionSent() } else { setPromotionFailed() @@ -583,6 +584,25 @@ class GroupManagerV2Impl @Inject constructor( .forEach(configs.groupMembers::set) } + val failedMembers = promotedByMemberIDs + .filterValues { it.isFailure } + .keys + .toList() + + if (failedMembers.isNotEmpty()) { + val cause = promotedByMemberIDs.values + .firstOrNull { it.isFailure } + ?.exceptionOrNull() + ?: RuntimeException("Failed to promote ${failedMembers.size} member(s)") + + throw GroupInviteException( + isPromotion = true, + inviteeAccountIds = failedMembers.map { it.hexString }, + groupName = groupName ?: "", + isReinvite = isRepromote, + underlying = cause + ) + } if (!isRepromote) { messageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) @@ -1207,7 +1227,6 @@ class GroupManagerV2Impl @Inject constructor( negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel } - return GroupManagerV2.ConfirmDialogData( title = application.getString(title), message = message, @@ -1218,6 +1237,63 @@ class GroupManagerV2Impl @Inject constructor( ) } + override fun getAdminLeaveGroupDialogData( + groupId: AccountId, + name: String + ): GroupManagerV2.ConfirmDialogData? { + val title = R.string.groupLeave + var message: CharSequence = "" + var positiveButton = R.string.leave + var negativeButton = R.string.cancel + var positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm + var negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel + + if (isCurrentUserLastAdmin(groupId)) { + message = Phrase.from(application, R.string.groupOnlyAdmin) + .put(GROUP_NAME_KEY, name) + .format() + positiveButton = R.string.add + negativeButton = R.string.groupDelete + } else { + message = Phrase.from(application, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, name) + .format() + } + + return GroupManagerV2.ConfirmDialogData( + title = application.getString(title), + message = message, + positiveText = positiveButton, + negativeText = negativeButton, + positiveQaTag = positiveQaTag, + negativeQaTag = negativeQaTag, + ) + } + + override fun isCurrentUserLastAdmin(groupId: AccountId): Boolean { + val currentUserId = checkNotNull(storage.getUserPublicKey()) { "User public key is null" } + + val membersWithStatus = configFactory.withGroupConfigs(groupId) { + it.groupMembers.allWithStatus() + } + + var adminCount = 0 + var amAdmin = false + + for ((member, status) in membersWithStatus) { + val isAdminLike = status == GroupMember.Status.PROMOTION_ACCEPTED && !member.isRemoved(status) + if (!isAdminLike) continue + + adminCount++ + + if (member.accountId() == currentUserId) { + amAdmin = true + } + } + + return amAdmin && adminCount == 1 + } + private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) { val firstError = this.results.firstOrNull { it.code != 200 } require(firstError == null) { "$errorMessage: ${firstError!!.body}" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt new file mode 100644 index 0000000000..eb56e3f0df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -0,0 +1,295 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupInviteException +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.util.AvatarUtils + +/** + * Admin screen: + * - Shows admins + their promotion status + * - Lets you select admins with failed/sent promotions + * - Bottom tray: "Resend promotions" + * + * No removing members, no invites here. + */ +@HiltViewModel(assistedFactory = ManageGroupAdminsViewModel.Factory::class) +class ManageGroupAdminsViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group, + @Assisted private val navigator: UINavigator, + @Assisted private val openPromoteMembers: Boolean, + @ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val groupManager: GroupManagerV2, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : BaseGroupMembersViewModel( + groupAddress = groupAddress, + context = context, + storage = storage, + configFactory = configFactory, + avatarUtils = avatarUtils, + recipientRepository = recipientRepository +) { + private val groupId = groupAddress.accountId + + /** + * One option for admins for now: "Promote members" + */ + private val optionsList: List by lazy { + listOf( + OptionsItem( + // use plural version of this string resource + name = context.resources.getQuantityString(R.plurals.promoteMember, 2, 2), + icon = R.drawable.ic_add_admin_custom, + onClick = ::navigateToPromoteMembers + ) + ) + } + + private val _mutableSelectedAdmins = MutableStateFlow(emptySet()) + val selectedAdmins: StateFlow> = _mutableSelectedAdmins + + private val footerCollapsed = MutableStateFlow(false) + + private val _uiState = MutableStateFlow(UiState(options = optionsList)) + val uiState: StateFlow = _uiState + + init { + // Build footer from selected admins + collapsed state + viewModelScope.launch { + kotlinx.coroutines.flow.combine( + selectedAdmins, + footerCollapsed, + ::buildFooterState + ).collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + if (openPromoteMembers) { + // Only runs once for this nav entry, so no loop on back + navigateToPromoteMembers() + } + } + + fun onAdminItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedAdmins.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) + } + _mutableSelectedAdmins.value = newSet + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + private fun navigateToPromoteMembers() { + viewModelScope.launch { + navigator.navigate( + destination = ConversationSettingsDestination.RoutePromoteMembers(groupAddress), + debounce = false + ) + } + } + + private fun setLoading(isLoading : Boolean){ + _uiState.update { it.copy(inProgress = isLoading) } + } + + /** + * Send promotions to all selected admins (explicit selection from caller). + */ + fun onSendPromotionsClicked(selectedAdmins: Set) { + sendPromotions(members = selectedAdmins, isRepromote = false) + } + + /** + * Resend promotions using locally selected admins. + * Used in the parent screen with admin list + */ + fun onResendPromotionsClicked() { + sendPromotions(isRepromote = true) + } + + private fun sendPromotions( + members: Set = selectedAdmins.value, + isRepromote: Boolean + ) { + if (members.isEmpty()) return + + val accountIds = members.map { it.accountId } + + val resendingText = context.resources.getQuantityString( + R.plurals.resendingPromotion, + accountIds.size, + accountIds.size + ) + + showToast(resendingText) + + performGroupOperationCore( + showLoading = false, + setLoading = ::setLoading, + errorMessage = { err -> + if (err is GroupInviteException) { + err.format(context, recipientRepository).toString() + } else { + null + } + } + ) { + removeSearchState(clearSelection = true) + + groupManager.promoteMember( + groupId, + accountIds, + isRepromote = isRepromote + ) + } + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun clearSelection() { + _mutableSelectedAdmins.value = emptySet() + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + + val title = + if (count == 0) GetString("") + else { + GetString( + context.resources.getQuantityString( + R.plurals.adminSelected, + count, + count + ) + ) + } + + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.resendPromotion, count, count) + ), + buttonLabel = GetString(context.getString(R.string.resend)), + isDanger = false, + onClick = { onResendPromotionsClicked() } + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title, + footerActionItems = trayItems + ) + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ToggleFooter -> toggleFooter() + is Commands.CloseFooter, + is Commands.ClearSelection -> clearSelection() + is Commands.SelfClick -> showToast(context.getString(R.string.adminStatusYou)) + is Commands.MemberClick -> onAdminItemClicked(command.member) + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + data class UiState( + val options: List = emptyList(), + + val inProgress: Boolean = false, + + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState(), + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle: GetString = GetString(""), + val footerActionItems: List = emptyList() + ) + + data class OptionsItem( + val name: String, + @DrawableRes val icon: Int, + @StringRes val qaTag: Int? = null, + val onClick: () -> Unit + ) + + sealed interface Commands { + data object ToggleFooter : Commands + data object CloseFooter : Commands + data object ClearSelection : Commands + + data object SelfClick : Commands + + class RemoveSearchState(val clearSelection: Boolean) : Commands + data class SearchQueryChange(val query: String) : Commands + data class SearchFocusChange(val focus: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + navigator: UINavigator, + navigateToPromoteMembers: Boolean + ): ManageGroupAdminsViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt index 006a61dcd7..377a98cb0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -10,6 +10,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.getOrNull import org.session.libsession.database.StorageProtocol @@ -53,11 +55,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ) : BaseGroupMembersViewModel(groupAddress, context, storage, configFactory, avatarUtils, recipientRepository) { private val groupId = groupAddress.accountId - // Output: The name of the group. This is the current name of the group, not the name being edited. - val groupName: StateFlow = groupInfo - .map { it?.first?.name.orEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") - // Output: whether we should show the "add members" button val showAddMembers: StateFlow = groupInfo .map { it?.first?.isUserAdmin == true } @@ -87,7 +84,19 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ) } - private val _uiState = MutableStateFlow(UiState(options = optionsList)) + private val adminOptionsList: List by lazy { + listOf( + OptionsItem( + // use plural version of this string resource + name = context.resources.getQuantityString(R.plurals.promoteMember,2,2), + icon = R.drawable.ic_add_admin_custom, + onClick = ::navigateToInviteContacts + ), + ) + } + + private val _uiState = + MutableStateFlow(UiState(options = optionsList, adminOptions = adminOptionsList)) val uiState: StateFlow = _uiState private val showRemoveMembersDialog = MutableStateFlow(false) @@ -143,18 +152,17 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } fun onSendInviteClicked(contacts: Set
, shareHistory : Boolean) { - _uiState.update { - it.copy( - ongoingAction = context.resources.getQuantityString( - R.plurals.groupInviteSending, - contacts.size, - contacts.size - ) - ) - } + val sendInviteText = context.resources.getQuantityString( + R.plurals.groupInviteSending, + contacts.size, + contacts.size + ) - performGroupOperation( + showToast(sendInviteText) + + performGroupOperationCore( showLoading = false, + setLoading = ::setLoading, errorMessage = { err -> if (err is GroupInviteException) { err.format(context, recipientRepository).toString() @@ -174,8 +182,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( fun onResendInviteClicked() { if (selectedMembers.value.isEmpty()) return - performGroupOperation( + performGroupOperationCore( showLoading = false, + setLoading = ::setLoading, errorMessage = { err -> if (err is GroupInviteException) { err.format(context, recipientRepository).toString() @@ -195,12 +204,15 @@ class ManageGroupMembersViewModel @AssistedInject constructor( removeSearchState(true) - _uiState.update { it -> - it.copy(error = context.resources.getQuantityString( - R.plurals.resendingInvite, - invites.size, - invites.size - )) + val errorText = context.resources.getQuantityString( + R.plurals.resendingInvite, + invites.size, + invites.size + ) + + // is it better move the invites list outside the operation? + withContext(Dispatchers.Main) { + showToast(errorText) // now safely on main thread } // Reinvite with per-member shareHistory @@ -220,22 +232,16 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onPromoteContact(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = false) - } - } - fun onRemoveContact(removeMessages: Boolean) { - _uiState.update { it -> - it.copy(ongoingAction =context.resources.getQuantityString( - R.plurals.removingMember, - selectedMembers.value.size, - selectedMembers.value.size - )) - } + val removeText = context.resources.getQuantityString( + R.plurals.removingMember, + selectedMembers.value.size, + selectedMembers.value.size + ) + + showToast(removeText) - performGroupOperation(showLoading = false) { + performGroupOperationCore(showLoading = false, setLoading = ::setLoading) { val accountIdList = selectedMembers.value.map { it.accountId } removeSearchState(true) @@ -248,55 +254,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onResendPromotionClicked(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = true) - } - } - - fun onDismissError() { - _uiState.update { it.copy(error = null) } - } - - /** - * Perform a group operation, such as inviting a member, removing a member. - * - * This is a helper function that encapsulates the common error handling and progress tracking. - */ - private fun performGroupOperation( - showLoading: Boolean = true, - errorMessage: ((Throwable) -> String?)? = null, - operation: suspend () -> Unit - ) { - viewModelScope.launch { - if (showLoading) { - _uiState.update { it.copy(inProgress = true) } - } - - // We need to use GlobalScope here because we don't want - // any group operation to be cancelled when the view model is cleared. - @Suppress("OPT_IN_USAGE") - val task = GlobalScope.async { - operation() - } - - try { - task.await() - } catch (e: Exception) { - _uiState.update { - it.copy( - error = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) - ) - } - } finally { - if (showLoading) { - _uiState.update { it.copy(inProgress = false) } - } - } - } - } - fun clearSelection(){ _mutableSelectedMembers.value = emptySet() } @@ -305,14 +262,14 @@ class ManageGroupMembersViewModel @AssistedInject constructor( footerCollapsed.update { !it } } - fun onDismissResend() { - _uiState.update { it.copy(ongoingAction = null) } - } - private fun toggleRemoveMembersDialog(visible : Boolean){ showRemoveMembersDialog.value = visible } + private fun setLoading(isLoading : Boolean){ + _uiState.update { it.copy(inProgress = true) } + } + fun onCommand(command: Commands) { when (command) { is Commands.ShowRemoveMembersDialog -> toggleRemoveMembersDialog(true) @@ -327,10 +284,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( is Commands.ToggleFooter -> toggleFooter() - is Commands.DismissError -> onDismissError() - - is Commands.DismissResend -> onDismissResend() - is Commands.MemberClick -> onMemberItemClicked(command.member) is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) @@ -426,10 +379,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( data class UiState( val options : List = emptyList(), + val adminOptions : List = emptyList(), val inProgress: Boolean = false, - val error: String? = null, - val ongoingAction: String? = null, // search UI state: val searchQuery: String = "", @@ -467,10 +419,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( data object ShowRemoveMembersDialog : Commands data object DismissRemoveMembersDialog : Commands - data object DismissError : Commands - - data object DismissResend : Commands - data object ToggleFooter : Commands data object CloseFooter : Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt new file mode 100644 index 0000000000..9cd0f408bd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -0,0 +1,251 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.util.AvatarUtils + +@HiltViewModel(assistedFactory = PromoteMembersViewModel.Factory::class) +class PromoteMembersViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group, + @ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val groupManager: GroupManagerV2, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : BaseGroupMembersViewModel( + groupAddress = groupAddress, + context = context, + storage = storage, + configFactory = configFactory, + avatarUtils = avatarUtils, + recipientRepository = recipientRepository +) { + private val groupId = groupAddress.accountId + + private val _mutableSelectedMembers = MutableStateFlow(emptySet()) + val selectedMembers: StateFlow> = _mutableSelectedMembers + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + private val _footerCollapsed = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine( + selectedMembers, + _footerCollapsed, + ::buildFooterState + ).collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + viewModelScope.launch { + selectedMembers + .map { selected -> buildPromoteDialogBody(selected) } + .collect { body -> + _uiState.update { it.copy(promoteDialogBody = body) } + } + } + } + + fun onMemberItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedMembers.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) + } + _mutableSelectedMembers.value = newSet + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + fun toggleFooter() { + _footerCollapsed.update { !it } + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun clearSelection() { + _mutableSelectedMembers.value = emptySet() + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + + val title = + if (count == 0) GetString("") + else { + GetString( + context.resources.getQuantityString( + R.plurals.memberSelected, + count, + count + ) + ) + } + + val footerAction = GetString( + context.resources.getQuantityString( + R.plurals.promoteMember, + count, count + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerTitle = title, + footerActionLabel = footerAction + ) + } + + private fun buildPromoteDialogBody( + selected: Set + ): String { + val count = selected.size + val firstMember = selected.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> { + Phrase.from(context, R.string.adminPromoteDescription) + .put(NAME_KEY, firstMember?.name) + .format() + } + + 2 -> { + val secondMember = selected.elementAtOrNull(1)?.name + Phrase.from(context, R.string.adminPromoteTwoDescription) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.adminPromoteMoreDescription) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .format() + } + + return body.toString() + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowPromoteDialog -> { + _uiState.update { it.copy(showPromoteDialog = true) } + } + + is Commands.DismissPromoteDialog -> { + _uiState.update { it.copy(showPromoteDialog = false) } + } + + is Commands.ShowConfirmDialog -> { + _uiState.update { it.copy(showConfirmDialog = true) } + } + + is Commands.DismissConfirmDialog -> { + _uiState.update { it.copy(showConfirmDialog = false) } + } + + is Commands.ToggleFooter -> toggleFooter() + + is Commands.CloseFooter, + is Commands.ClearSelection -> clearSelection() + + is Commands.MemberClick -> onMemberItemClicked(command.member) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + sealed interface Commands { + data object ShowPromoteDialog : Commands + data object DismissPromoteDialog : Commands + + data object ShowConfirmDialog : Commands + data object DismissConfirmDialog : Commands + + data object ToggleFooter : Commands + data object CloseFooter : Commands + data object ClearSelection : Commands + + data class RemoveSearchState(val clearSelection: Boolean) : Commands + data class SearchQueryChange(val query: String) : Commands + data class SearchFocusChange(val focus: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + data class UiState( + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + val showConfirmDialog: Boolean = false, + + val showPromoteDialog: Boolean = false, + val promoteDialogBody: String = "", + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerTitle: GetString = GetString(""), + val footerActionLabel: GetString = GetString("") + ) + + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + ): PromoteMembersViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index 09428ee146..2c11a16dbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonData @@ -139,7 +140,8 @@ fun RadioMemberItem( showProBadge: Boolean, modifier: Modifier = Modifier, subtitle: String? = null, - subtitleColor: Color = LocalColors.current.textSecondary + subtitleColor: Color = LocalColors.current.textSecondary, + showRadioButton: Boolean = true ) { MemberItem( address = address, @@ -152,10 +154,12 @@ fun RadioMemberItem( showProBadge = showProBadge, modifier = modifier ) { - RadioButtonIndicator( - selected = selected, - enabled = enabled - ) + if (showRadioButton) { + RadioButtonIndicator( + selected = selected, + enabled = enabled + ) + } } } @@ -239,6 +243,33 @@ fun InviteMembersDialog( ) } +@Composable +fun ManageMemberItem( + member: GroupMemberState, + onClick: (address: Address) -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false +) { + RadioMemberItem( + address = Address.fromSerialized(member.accountId.hexString), + title = member.name, + subtitle = member.statusLabel, + subtitleColor = if (member.highlightStatus) { + LocalColors.current.danger + } else { + LocalColors.current.textSecondary + }, + showAsAdmin = member.showAsAdmin, + showProBadge = member.showProBadge, + avatarUIData = member.avatarUIData, + onClick = onClick, + modifier = modifier, + enabled = true, + selected = selected, + showRadioButton = !member.isSelf + ) +} + @Preview @Composable fun PreviewMemberList() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt index 7b69716593..af8317d772 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt @@ -138,6 +138,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -160,6 +161,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -182,6 +184,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) GroupMembers( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt new file mode 100644 index 0000000000..d1e0f2953d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt @@ -0,0 +1,291 @@ +package org.thoughtcrime.securesms.groups.compose + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import network.loki.messenger.R +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.* +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.getCellBottomShape +import org.thoughtcrime.securesms.ui.getCellTopShape +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@Composable +fun ManageGroupAdminsScreen( + viewModel: ManageGroupAdminsViewModel, + onBack: () -> Unit, +) { + ManageAdmins( + onBack = onBack, + uiState = viewModel.uiState.collectAsState().value, + admins = viewModel.adminMembers.collectAsState().value, + selectedMembers = viewModel.selectedAdmins.collectAsState().value, + searchQuery = viewModel.searchQuery.collectAsState().value, + sendCommand = viewModel::onCommand, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageAdmins( + onBack: () -> Unit, + uiState: ManageGroupAdminsViewModel.UiState, + searchQuery: String, + admins: List, + selectedMembers: Set = emptySet(), + sendCommand: (command: ManageGroupAdminsViewModel.Commands) -> Unit, +) { + + val searchFocused = uiState.isSearchFocused + + val handleBack: () -> Unit = { + when { + searchFocused -> sendCommand(RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + + Scaffold( + topBar = { + BackAppBar( + title = stringResource(id = R.string.manageAdmins), + onBack = handleBack, + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = uiState.footer.footerActionItems + ), + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } + ) + } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(R.string.adminCannotBeDemoted), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + + AnimatedVisibility( + // show only when add-members is enabled AND search is not focused + visible = !searchFocused, + enter = fadeIn(animationSpec = tween(150)) + + expandVertically( + animationSpec = tween(200), + expandFrom = Alignment.Top + ), + exit = fadeOut(animationSpec = tween(150)) + + shrinkVertically( + animationSpec = tween(180), + shrinkTowards = Alignment.Top + ) + ) { + Column { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalDimensions.current.smallSpacing), + ) { + Column { + uiState.options.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + uiState.options.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick, + ) + + if (index != uiState.options.lastIndex) Divider() + } + } + } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = LocalResources.current.getString(R.string.admins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(admins) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { + if (member.isSelf) sendCommand(SelfClick) + else sendCommand(MemberClick(member)) + }, + selected = member in selectedMembers + ) + } + + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } + } + + if (uiState.inProgress) { + LoadingDialog() + } +} + + +@Preview +@Composable +private fun PreviewManageAdmins( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ManageAdmins( + onBack = {}, + admins = listOf(), + searchQuery = "", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupAdminsViewModel.UiState( + options = emptyList(), + footer = ManageGroupAdminsViewModel.CollapsibleFooterState( + visible = false, + collapsed = true, + footerActionTitle = GetString("2 Admins Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt index 6c8b49ea59..17e79fd96c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import network.loki.messenger.R import network.loki.messenger.libsession_util.util.GroupMember -import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel @@ -112,8 +111,6 @@ fun ManageMembers( ) { val searchFocused = uiState.isSearchFocused - val showingError = uiState.error - val showingOngoingAction = uiState.ongoingAction val handleBack: () -> Unit = { when { @@ -262,7 +259,7 @@ fun ManageMembers( } if (uiState.removeMembersDialog.visible) { - ShowRemoveMembersDialog( + RemoveMembersDialog( state = uiState.removeMembersDialog, sendCommand = sendCommand ) @@ -271,51 +268,10 @@ fun ManageMembers( if (uiState.inProgress) { LoadingDialog() } - - val context = LocalContext.current - - LaunchedEffect(showingError) { - if (showingError != null) { - Toast.makeText(context, showingError, Toast.LENGTH_SHORT).show() - sendCommand(DismissError) - } - } - LaunchedEffect(showingOngoingAction) { - if (showingOngoingAction != null) { - Toast.makeText(context, showingOngoingAction, Toast.LENGTH_SHORT).show() - sendCommand(DismissResend) - } - } -} - -@Composable -fun ManageMemberItem( - member: GroupMemberState, - onClick: (address: Address) -> Unit, - modifier: Modifier = Modifier, - selected: Boolean = false -) { - RadioMemberItem( - address = Address.fromSerialized(member.accountId.hexString), - title = member.name, - subtitle = member.statusLabel, - subtitleColor = if (member.highlightStatus) { - LocalColors.current.danger - } else { - LocalColors.current.textSecondary - }, - showAsAdmin = member.showAsAdmin, - showProBadge = member.showProBadge, - avatarUIData = member.avatarUIData, - onClick = onClick, - modifier = modifier, - enabled = true, - selected = selected - ) } @Composable -fun ShowRemoveMembersDialog( +fun RemoveMembersDialog( state: ManageGroupMembersViewModel.RemoveMembersDialogState, modifier: Modifier = Modifier, sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit @@ -414,6 +370,7 @@ private fun EditGroupPreviewSheet() { showProBadge = true, clickable = true, statusLabel = "Invited", + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -435,7 +392,8 @@ private fun EditGroupPreviewSheet() { showAsAdmin = true, showProBadge = true, clickable = true, - statusLabel = "Promotion failed" + statusLabel = "Promotion failed", + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -457,7 +415,8 @@ private fun EditGroupPreviewSheet() { showAsAdmin = false, showProBadge = false, clickable = true, - statusLabel = "" + statusLabel = "", + isSelf = true ) val (_, _) = remember { mutableStateOf(null) } @@ -510,6 +469,7 @@ private fun EditGroupEditNamePreview( showProBadge = true, clickable = true, statusLabel = "Invited", + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -531,7 +491,8 @@ private fun EditGroupEditNamePreview( showAsAdmin = true, showProBadge = true, clickable = true, - statusLabel = "Promotion failed" + statusLabel = "Promotion failed", + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -553,7 +514,8 @@ private fun EditGroupEditNamePreview( showAsAdmin = false, showProBadge = false, clickable = true, - statusLabel = "" + statusLabel = "", + isSelf = false ) ManageMembers( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt new file mode 100644 index 0000000000..dd5335825c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -0,0 +1,288 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import network.loki.messenger.R +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.CloseFooter +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.DismissConfirmDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.DismissPromoteDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.MemberClick +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SearchFocusChange +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SearchQueryChange +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowConfirmDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowPromoteDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ToggleFooter +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +fun PromoteMembersScreen( + viewModel: PromoteMembersViewModel, + onBack: () -> Unit, + onPromoteClicked: (Set) -> Unit +) { + val uiState = viewModel.uiState.collectAsState().value + val searchQuery = viewModel.searchQuery.collectAsState().value + val hasActiveMembers = viewModel.hasActiveMembers.collectAsState().value + val members = viewModel.activeMembers.collectAsState().value + val selectedMembers = viewModel.selectedMembers.collectAsState().value + + PromoteMembers( + onBack = onBack, + uiState = uiState, + searchQuery = searchQuery, + sendCommand = viewModel::onCommand, + members = members, + selectedMembers = selectedMembers, + hasActiveMembers = hasActiveMembers, + onPromoteClicked = onPromoteClicked + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromoteMembers( + onBack: () -> Unit, + uiState: PromoteMembersViewModel.UiState, + searchQuery: String, + sendCommand: (command: Commands) -> Unit, + members: List, + selectedMembers: Set = emptySet(), + hasActiveMembers: Boolean = false, + onPromoteClicked: (Set) -> Unit +) { + val searchFocused = uiState.isSearchFocused + + val handleBack: () -> Unit = { + when { + searchFocused -> sendCommand(Commands.RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + + + Scaffold( + topBar = { + BackAppBar( + title = pluralStringResource(id = R.plurals.promoteMember, 2), + onBack = handleBack, + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = uiState.footer.footerTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = listOf( + CollapsibleFooterItemData( + label = uiState.footer.footerActionLabel, + buttonLabel = GetString(LocalResources.current.getString(R.string.promote)), + isDanger = false, + onClick = { sendCommand(ShowPromoteDialog) } + ) + ) + ), + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } + ) + } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(if (!hasActiveMembers) R.string.noNonAdminsInGroup else R.string.adminCannotBeDemoted), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + + if (hasActiveMembers) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(members) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { sendCommand(MemberClick(member)) }, + selected = member in selectedMembers + ) + } + + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } + } + } + + if (uiState.showConfirmDialog) { + ConfirmDialog( + sendCommand = sendCommand, + onConfirmClicked = { onPromoteClicked(selectedMembers) }) + } + + if (uiState.showPromoteDialog) { + PromotionDialog(sendCommand = sendCommand, bodyText = uiState.promoteDialogBody) + } +} + +@Composable +fun ConfirmDialog( + modifier: Modifier = Modifier, + onConfirmClicked: () -> Unit, + sendCommand: (Commands) -> Unit +) { + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(DismissConfirmDialog) + }, + title = annotatedStringResource(R.string.confirmPromotion), + text = annotatedStringResource(R.string.confirmPromotionDescription), + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissConfirmDialog) + } + ), + DialogButtonData( + text = GetString(stringResource(id = R.string.confirm)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissConfirmDialog) + onConfirmClicked() + } + ) + ) + ) +} + +@Composable +fun PromotionDialog( + modifier: Modifier = Modifier, + sendCommand: (Commands) -> Unit, + bodyText: String +) { + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(DismissPromoteDialog) + }, + title = stringResource(R.string.promote), + text = bodyText, + showCloseButton = true, + content = { + Text( + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + text = LocalResources.current.getString(R.string.promoteAdminsWarning), + style = LocalType.current.small, + color = LocalColors.current.warning, + textAlign = TextAlign.Center + ) + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.promote)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissPromoteDialog) + sendCommand(ShowConfirmDialog) + + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissPromoteDialog) + } + ) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index 99e8af2de3..8bc00bf525 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -75,6 +75,8 @@ data class SimpleDialogData( val message: CharSequence, val positiveText: String? = null, val positiveStyleDanger: Boolean = true, + + val negativeStyleDanger: Boolean = false, val showXIcon: Boolean = false, val negativeText: String? = null, val positiveQaTag: String? = null, @@ -245,8 +247,8 @@ fun AlertDialogContent( color = it.color, enabled = it.enabled ) { - it.onClick() if (it.dismissOnClick) onDismissRequest() + it.onClick() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt index 8aa4c05bc3..b16d1ca55e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt @@ -18,10 +18,11 @@ class UINavigator () { suspend fun navigate( destination: T, - navOptions: NavOptionsBuilder.() -> Unit = {} + navOptions: NavOptionsBuilder.() -> Unit = {}, + debounce : Boolean = true // For when intentionally chaining navigations ) { val currentTime = System.currentTimeMillis() - if (currentTime - lastNavigationTime > navigationDebounceTime) { + if (!debounce || currentTime - lastNavigationTime > navigationDebounceTime) { lastNavigationTime = currentTime _navigationActions.send(NavigationAction.Navigate( destination = destination, diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index c977a454df..2695268dcd 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -149,6 +149,7 @@ delete-group-confirm-button delete-group-cancel-button leave-group-confirm-button + add-admin-button leave-group-cancel-button clear-all-messages-confirm-button clear-all-messages-cancel-button