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..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 -> leaveConversation(conversation) + is ConversationOpsAction.Leave -> showLeaveConversationSnackbar(conversation) is ConversationOpsAction.Delete -> showDeleteConversationDialog(conversation) is ConversationOpsAction.ManageTags -> conversationTagsViewModel.setConversationForTagAssignment( conversation @@ -1223,10 +1225,33 @@ class ConversationsListActivity : BaseActivity() { } } + /** + * 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 + ) + when (result) { + SnackbarResult.ActionPerformed -> conversationsListViewModel.clearConversationPendingLeave(token) + SnackbarResult.Dismissed -> leaveConversation(conversation) + } + } + } + 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) @@ -1240,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/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 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 8c4831830f..bbe92cd498 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 + Undo Delete all messages Do you really want to delete all messages in this conversation? All messages were deleted