From 9c7d97d72a945599fafb961c757448773987f27e Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Fri, 28 Aug 2026 12:03:45 -0500 Subject: [PATCH 1/3] fix(conversationlist): Add confirmation dialog before leaving a conversation via swipe Swiping left on a conversation triggered leaving it immediately with no way to confirm or cancel, making it easy to accidentally leave a group. Show a confirmation dialog, matching the existing delete conversation dialog pattern. Fixes #6607 Signed-off-by: rapterjet2004 --- .../ConversationsListActivity.kt | 25 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 21e143ee0a..53984610ea 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1167,7 +1167,7 @@ class ConversationsListActivity : BaseActivity() { is ConversationOpsAction.Rename -> renameConversation(conversation) is ConversationOpsAction.ToggleArchive -> handleArchiving(conversation) is ConversationOpsAction.AddToHomeScreen -> addConversationToHomeScreen(conversation) - is ConversationOpsAction.Leave -> leaveConversation(conversation) + is ConversationOpsAction.Leave -> showLeaveConversationDialog(conversation) is ConversationOpsAction.Delete -> showDeleteConversationDialog(conversation) is ConversationOpsAction.ManageTags -> conversationTagsViewModel.setConversationForTagAssignment( conversation @@ -1223,6 +1223,29 @@ class ConversationsListActivity : BaseActivity() { } } + private fun showLeaveConversationDialog(conversation: ConversationModel) { + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setIcon( + viewThemeUtils.dialog + .colorMaterialAlertDialogIcon(context, R.drawable.ic_exit_to_app_black_24dp) + ) + .setTitle(R.string.nc_leave) + .setMessage(R.string.nc_leave_conversation_warning) + .setPositiveButton(R.string.nc_leave) { _, _ -> + leaveConversation(conversation) + } + .setNegativeButton(R.string.nc_cancel) { _, _ -> + } + + viewThemeUtils.dialog + .colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + ) + } + @SuppressLint("StringFormatInvalid") private fun leaveConversation(conversation: ConversationModel) { val data = Data.Builder() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c4831830f..c8fb3fc334 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -334,6 +334,7 @@ How to translate with transifex: Leave conversation + Do you really want to leave this conversation? Delete all messages Do you really want to delete all messages in this conversation? All messages were deleted From d44bec19429198a8bda5a918663e646bb4374e72 Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Mon, 31 Aug 2026 09:57:39 -0500 Subject: [PATCH 2/3] fix(conversationlist): Replace leave confirmation dialog with undo snackbar The blocking confirmation dialog interrupted the swipe-to-leave flow. Show a snackbar with an Undo action instead, matching the pattern email clients use. The room is hidden optimistically and the actual leave request is only sent once the snackbar times out without being undone, so leaving stays reversible even for rooms only a moderator could re-add you to. Addresses review feedback on #6607 Signed-off-by: rapterjet2004 --- .../ConversationsListActivity.kt | 59 ++++++++++--------- .../viewmodels/ConversationsListViewModel.kt | 37 ++++++++---- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 59 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 53984610ea..a043e9767b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -23,7 +23,9 @@ import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.appcompat.app.AlertDialog +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat @@ -1167,7 +1169,7 @@ class ConversationsListActivity : BaseActivity() { is ConversationOpsAction.Rename -> renameConversation(conversation) is ConversationOpsAction.ToggleArchive -> handleArchiving(conversation) is ConversationOpsAction.AddToHomeScreen -> addConversationToHomeScreen(conversation) - is ConversationOpsAction.Leave -> showLeaveConversationDialog(conversation) + is ConversationOpsAction.Leave -> showLeaveConversationSnackbar(conversation) is ConversationOpsAction.Delete -> showDeleteConversationDialog(conversation) is ConversationOpsAction.ManageTags -> conversationTagsViewModel.setConversationForTagAssignment( conversation @@ -1223,33 +1225,33 @@ class ConversationsListActivity : BaseActivity() { } } - private fun showLeaveConversationDialog(conversation: ConversationModel) { - val dialogBuilder = MaterialAlertDialogBuilder(this) - .setIcon( - viewThemeUtils.dialog - .colorMaterialAlertDialogIcon(context, R.drawable.ic_exit_to_app_black_24dp) + /** + * Rather than blocking with a confirmation dialog, hide the conversation immediately and offer + * an "Undo" snackbar. The actual leave-conversation network call is deferred until the snackbar + * goes away without being undone, so a room only needs to be rejoined if the user missed the + * undo window. + */ + @SuppressLint("StringFormatInvalid") + private fun showLeaveConversationSnackbar(conversation: ConversationModel) { + val token = conversation.token ?: return + conversationsListViewModel.markConversationPendingLeave(token) + lifecycleScope.launch { + val result = snackbarHostState.showSnackbar( + message = String.format(resources.getString(R.string.left_conversation), conversation.displayName), + actionLabel = getString(R.string.nc_undo), + duration = SnackbarDuration.Long ) - .setTitle(R.string.nc_leave) - .setMessage(R.string.nc_leave_conversation_warning) - .setPositiveButton(R.string.nc_leave) { _, _ -> - leaveConversation(conversation) + when (result) { + SnackbarResult.ActionPerformed -> conversationsListViewModel.clearConversationPendingLeave(token) + SnackbarResult.Dismissed -> leaveConversation(conversation) } - .setNegativeButton(R.string.nc_cancel) { _, _ -> - } - - viewThemeUtils.dialog - .colorMaterialAlertDialogBackground(this, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) - ) + } } - @SuppressLint("StringFormatInvalid") private fun leaveConversation(conversation: ConversationModel) { + val token = conversation.token ?: return val data = Data.Builder() - .putString(KEY_ROOM_TOKEN, conversation.token) + .putString(KEY_ROOM_TOKEN, token) .putLong(KEY_INTERNAL_USER_ID, currentUser?.id!!) .build() val worker = OneTimeWorkRequest.Builder(LeaveConversationWorker::class.java) @@ -1263,17 +1265,18 @@ class ConversationsListActivity : BaseActivity() { currentUser?.id?.let { userId -> ShortcutManagerHelper.disableConversationShortcut( this, - conversation.token, + token, userId, resources.getString(R.string.nc_shortcut_conversation_deleted) ) } - showSnackbar( - String.format(resources.getString(R.string.left_conversation), conversation.displayName) - ) - startActivity(Intent(this, MainActivity::class.java)) + conversationsListViewModel.clearConversationPendingLeave(token) + fetchRooms() + } + WorkInfo.State.FAILED -> { + conversationsListViewModel.clearConversationPendingLeave(token) + showSnackbar(resources.getString(R.string.nc_common_error_sorry)) } - WorkInfo.State.FAILED -> showSnackbar(resources.getString(R.string.nc_common_error_sorry)) else -> {} } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index f890e3f730..e90f45cc50 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -235,6 +235,13 @@ class ConversationsListViewModel @Inject constructor( private val hideRoomToken = MutableStateFlow(null) + /** Tokens of rooms being left; hidden optimistically while the leave-undo snackbar is showing. */ + private val pendingLeaveTokens = MutableStateFlow>(emptySet()) + + private val excludedRoomTokens = combine(hideRoomToken, pendingLeaveTokens) { hideToken, pendingTokens -> + if (hideToken != null) pendingTokens + hideToken else pendingTokens + } + private enum class SearchDisplayMode { OFF, ALL_CONVERSATIONS, @@ -272,9 +279,9 @@ class ConversationsListViewModel @Inject constructor( _filterStateFlow, searchDisplayModeFlow, combine(_selectedTagFilterFlow, selectedTagIsFavoritesFlow, ::TagFilterSelection), - combine(searchResultEntries, hideRoomToken, ::Pair) - ) { rooms, filterState, searchMode, tagFilter, (searchResults, hideToken) -> - buildConversationListEntries(rooms, filterState, searchMode, tagFilter, searchResults, hideToken) + combine(searchResultEntries, excludedRoomTokens, ::Pair) + ) { rooms, filterState, searchMode, tagFilter, (searchResults, excludedTokens) -> + buildConversationListEntries(rooms, filterState, searchMode, tagFilter, searchResults, excludedTokens) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) /** @@ -288,9 +295,9 @@ class ConversationsListViewModel @Inject constructor( getRoomsStateFlow, _filterStateFlow, searchDisplayModeFlow, - hideRoomToken - ) { rooms, filterState, searchMode, hideToken -> - baseFilterRooms(rooms, filterState, searchMode, hideToken) + excludedRoomTokens + ) { rooms, filterState, searchMode, excludedTokens -> + baseFilterRooms(rooms, filterState, searchMode, excludedTokens) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) /** Clears the tag filter when the filtered-by tag no longer exists (e.g. it was deleted). */ @@ -373,6 +380,16 @@ class ConversationsListViewModel @Inject constructor( hideRoomToken.value = token } + /** Optimistically hide a room while its leave-undo snackbar is showing. */ + fun markConversationPendingLeave(token: String) { + pendingLeaveTokens.value = pendingLeaveTokens.value + token + } + + /** Un-hide a room, either because the leave was undone or because it finished/failed. */ + fun clearConversationPendingLeave(token: String) { + pendingLeaveTokens.value = pendingLeaveTokens.value - token + } + fun getFederationInvitations() { _federationInvitationHintVisible.value = false _showAvatarBadge.value = false @@ -654,11 +671,11 @@ class ConversationsListViewModel @Inject constructor( searchMode: SearchDisplayMode, tagFilter: TagFilterSelection, searchResults: List, - hideToken: String? + excludedTokens: Set ): List { if (searchMode == SearchDisplayMode.RESULTS) return searchResults - var filtered = baseFilterRooms(rooms, filterState, searchMode, hideToken) + var filtered = baseFilterRooms(rooms, filterState, searchMode, excludedTokens) if (searchMode != SearchDisplayMode.ALL_CONVERSATIONS) { filtered = when { @@ -684,14 +701,14 @@ class ConversationsListViewModel @Inject constructor( rooms: List, filterState: Map, searchMode: SearchDisplayMode, - hideToken: String? + excludedTokens: Set ): List { val hasFilterEnabled = filterState[MENTION] == true || filterState[UNREAD] == true || filterState[ARCHIVE] == true var filtered = rooms - .filter { it.token != hideToken } + .filter { it.token !in excludedTokens } .filter { conversation -> !( conversation.objectType == ConversationEnums.ObjectType.ROOM && diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8fb3fc334..bbe92cd498 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -334,7 +334,7 @@ How to translate with transifex: Leave conversation - Do you really want to leave this conversation? + Undo Delete all messages Do you really want to delete all messages in this conversation? All messages were deleted From 08188ef9d2db0851c9b9a9417a715dc9fe853253 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 2 Sep 2026 12:55:17 +0200 Subject: [PATCH 3/3] style: Move leave-snackbar between navbar and fab/unread buttons Signed-off-by: Andy Scherzinger --- .../ui/ConversationListFab.kt | 3 +- .../ui/ConversationsListScreen.kt | 44 ++++++++++--------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt index cc915b46fa..e7b43feda7 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt @@ -41,9 +41,10 @@ private const val FAB_ANIM_DURATION = 200 private const val UNREAD_MENTIONS_HORIZONTAL_SPACING = 88 @Composable -fun ConversationListFab(isVisible: Boolean, isEnabled: Boolean, onClick: () -> Unit) { +fun ConversationListFab(isVisible: Boolean, isEnabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { AnimatedVisibility( visible = isVisible, + modifier = modifier, enter = scaleIn(animationSpec = tween(FAB_ANIM_DURATION)) + fadeIn(animationSpec = tween(FAB_ANIM_DURATION)), exit = scaleOut(animationSpec = tween(FAB_ANIM_DURATION)) + fadeOut(animationSpec = tween(FAB_ANIM_DURATION)) ) { diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index 3338f34144..a726cb35b9 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -74,6 +74,7 @@ import kotlinx.coroutines.launch private const val SEARCH_DEBOUNCE_MS = 300 private const val SEARCH_MIN_CHARS = 1 +private const val OVERLAY_MARGIN = 16 @Suppress("LongParameterList") data class ConversationsListScreenState( @@ -273,19 +274,6 @@ fun ConversationsListScreen( ) ) } - }, - floatingActionButton = { - ConversationListFab( - isVisible = isFabVisible && !isSearchActive, - isEnabled = isOnline, - onClick = callbacks.onFabClick - ) - }, - snackbarHost = { - SnackbarHost( - hostState = state.snackbarHostState, - modifier = Modifier.navigationBarsPadding() - ) } ) { paddingValues -> val layoutDirection = LocalLayoutDirection.current @@ -386,15 +374,31 @@ fun ConversationsListScreen( } } - // Unread-mention bubble (bottom-center overlay) - UnreadMentionBubble( - visible = showUnreadBubble && !isSearchActive, - onClick = callbacks.onUnreadBubbleClick, + Column( modifier = Modifier .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(bottom = 16.dp) - ) + .fillMaxWidth() + .padding(bottom = paddingValues.calculateBottomPadding()) + ) { + Box(modifier = Modifier.fillMaxWidth()) { + UnreadMentionBubble( + visible = showUnreadBubble && !isSearchActive, + onClick = callbacks.onUnreadBubbleClick, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = OVERLAY_MARGIN.dp) + ) + ConversationListFab( + isVisible = isFabVisible && !isSearchActive, + isEnabled = isOnline, + onClick = callbacks.onFabClick, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = OVERLAY_MARGIN.dp, bottom = OVERLAY_MARGIN.dp) + ) + } + SnackbarHost(hostState = state.snackbarHostState) + } } // Account-chooser dialog