diff --git a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt index d26a159736c..7b2469fbe31 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt @@ -28,6 +28,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.wire.android.BuildConfig import com.wire.android.feature.AppLockSource +import com.wire.android.ui.home.conversations.messages.item.MessageSwipeAction import com.wire.android.ui.theme.ThemeOption import com.wire.android.util.sha256 import com.wire.android.di.ApplicationContext @@ -55,6 +56,8 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex private val APP_LOCK_PASSCODE = stringPreferencesKey("app_lock_passcode") private val APP_LOCK_SOURCE = intPreferencesKey("app_lock_source") private val ENTER_TO_SENT = booleanPreferencesKey("enter_to_sent") + private val MESSAGE_SWIPE_RIGHT_ACTION = stringPreferencesKey("message_swipe_right_action") + private val MESSAGE_SWIPE_LEFT_ACTION = stringPreferencesKey("message_swipe_left_action") private val ANONYMOUS_REGISTRATION_TRACK_ID = stringPreferencesKey("anonymous_registration_track_id") private val IS_ANONYMOUS_REGISTRATION_ENABLED = booleanPreferencesKey("is_anonymous_registration_enabled") private val PERSISTENT_WEBSOCKET_ENFORCED_BY_MDM = booleanPreferencesKey("persistent_websocket_enforced_by_mdm") @@ -208,6 +211,22 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex context.dataStore.edit { it[ENTER_TO_SENT] = enabled } } + fun messageSwipeRightActionFlow(): Flow = + getStringPreference(MESSAGE_SWIPE_RIGHT_ACTION, MessageSwipeAction.DEFAULT_RIGHT.name) + .map { MessageSwipeAction.fromStoredValue(it, MessageSwipeAction.DEFAULT_RIGHT) } + + suspend fun setMessageSwipeRightAction(action: MessageSwipeAction) { + context.dataStore.edit { it[MESSAGE_SWIPE_RIGHT_ACTION] = action.name } + } + + fun messageSwipeLeftActionFlow(): Flow = + getStringPreference(MESSAGE_SWIPE_LEFT_ACTION, MessageSwipeAction.DEFAULT_LEFT.name) + .map { MessageSwipeAction.fromStoredValue(it, MessageSwipeAction.DEFAULT_LEFT) } + + suspend fun setMessageSwipeLeftAction(action: MessageSwipeAction) { + context.dataStore.edit { it[MESSAGE_SWIPE_LEFT_ACTION] = action.name } + } + fun isPersistentWebSocketEnforcedByMDM(): Flow = getBooleanPreference(PERSISTENT_WEBSOCKET_ENFORCED_BY_MDM, false) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelFactory.kt index c73765df445..a1f06f72487 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelFactory.kt @@ -220,6 +220,7 @@ class ConversationCoreViewModelFactory @Inject constructor( deleteMessage = deleteMessage, isWireCellFeatureEnabled = isWireCellsEnabled, networkStateObserver = networkStateObserver, + globalDataStore = globalDataStore, ) fun messageComposerViewModel(savedStateHandle: SavedStateHandle) = MessageComposerViewModel( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 21b04f7dd8b..87882f78772 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -169,7 +169,9 @@ import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewMod import com.wire.android.ui.home.conversations.messages.item.AssetLocalPathArgs import com.wire.android.ui.home.conversations.messages.item.MessageClickActions import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem +import com.wire.android.ui.home.conversations.messages.item.MessageSwipeAction import com.wire.android.ui.home.conversations.messages.item.SwipeableMessageConfiguration +import com.wire.android.ui.home.conversations.messages.item.toSwipeAction import com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel import com.wire.android.ui.home.conversations.model.ExpirationStatus import com.wire.android.ui.home.conversations.model.MessageSenderId @@ -1076,6 +1078,8 @@ private fun ConversationScreen( messageComposerStateHolder = messageComposerStateHolder, attachments = attachments, messages = conversationMessagesViewState.messages, + messageSwipeRightAction = conversationMessagesViewState.messageSwipeRightAction, + messageSwipeLeftAction = conversationMessagesViewState.messageSwipeLeftAction, onSendMessage = onSendMessage, onPingOptionClicked = onPingOptionClicked, onImagesPicked = onImagesPicked, @@ -1089,6 +1093,9 @@ private fun ConversationScreen( onUpdateConversationReadDate = onUpdateConversationReadDate, onShowEditingOptions = conversationScreenState::showEditContextMenu, onSwipedToReply = messageComposerStateHolder::toReply, + onSwipedToDetails = { message -> + onMessageDetailsClick(message.header.messageId, message.isMyMessage) + }, onSelfDeletingMessageRead = onSelfDeletingMessageRead, onFailedMessageCancelClicked = remember { { onDeleteMessage(it, false) } }, onFailedMessageRetryClicked = onFailedMessageRetryClicked, @@ -1164,6 +1171,8 @@ private fun ConversationScreenContent( messageComposerStateHolder: MessageComposerStateHolder, attachments: List, messages: Flow>, + messageSwipeRightAction: MessageSwipeAction, + messageSwipeLeftAction: MessageSwipeAction, onSendMessage: (MessageBundle) -> Unit, onPingOptionClicked: () -> Unit, onImagesPicked: (List, Boolean) -> Unit, @@ -1177,6 +1186,7 @@ private fun ConversationScreenContent( onUpdateConversationReadDate: (String) -> Unit, onShowEditingOptions: (UIMessage.Regular) -> Unit, onSwipedToReply: (UIMessage.Regular) -> Unit, + onSwipedToDetails: (UIMessage.Regular) -> Unit, onSelfDeletingMessageRead: (UIMessage) -> Unit, conversationDetailsData: ConversationDetailsData, onFailedMessageRetryClicked: (String, ConversationId) -> Unit, @@ -1238,6 +1248,9 @@ private fun ConversationScreenContent( onSwipedToReact = { message -> emojiPickerState.show(message.header.messageId) }, + onSwipedToDetails = onSwipedToDetails, + messageSwipeRightAction = messageSwipeRightAction, + messageSwipeLeftAction = messageSwipeLeftAction, conversationDetailsData = conversationDetailsData, selectedMessageId = selectedMessageId, interactionAvailability = messageComposerStateHolder.messageComposerViewState.value.interactionAvailability, @@ -1320,6 +1333,9 @@ fun MessageList( onUpdateConversationReadDate: (String) -> Unit, onSwipedToReply: (UIMessage.Regular) -> Unit, onSwipedToReact: (UIMessage.Regular) -> Unit, + onSwipedToDetails: (UIMessage.Regular) -> Unit, + messageSwipeRightAction: MessageSwipeAction, + messageSwipeLeftAction: MessageSwipeAction, onSelfDeletingMessageRead: (UIMessage) -> Unit, conversationDetailsData: ConversationDetailsData, selectedMessageId: String?, @@ -1491,12 +1507,33 @@ fun MessageList( } } - val swipeableConfiguration = remember(message, lazyListState.isScrollInProgress) { - if (!lazyListState.isScrollInProgress && message is UIMessage.Regular && message.isSwipeable) { - SwipeableMessageConfiguration.Swipeable( - onSwipedRight = { onSwipedToReply(message) }.takeIf { message.isReplyable }, - onSwipedLeft = { onSwipedToReact(message) }.takeIf { message.isReactionAllowed }, + val swipeableConfiguration = remember( + message, + lazyListState.isScrollInProgress, + messageSwipeRightAction, + messageSwipeLeftAction, + ) { + if (!lazyListState.isScrollInProgress && message is UIMessage.Regular) { + val rightSwipeAction = messageSwipeRightAction.toSwipeAction( + message = message, + onSwipedToReply = onSwipedToReply, + onSwipedToReact = onSwipedToReact, + onSwipedToDetails = onSwipedToDetails, + ) + val leftSwipeAction = messageSwipeLeftAction.toSwipeAction( + message = message, + onSwipedToReply = onSwipedToReply, + onSwipedToReact = onSwipedToReact, + onSwipedToDetails = onSwipedToDetails, ) + if (rightSwipeAction != null || leftSwipeAction != null) { + SwipeableMessageConfiguration.Swipeable( + onSwipedRight = rightSwipeAction, + onSwipedLeft = leftSwipeAction, + ) + } else { + SwipeableMessageConfiguration.NotSwipeable + } } else { SwipeableMessageConfiguration.NotSwipeable } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index 3852c84cf3a..ddd97a198db 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -29,6 +29,7 @@ import androidx.paging.cachedIn import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.datastore.GlobalDataStore import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.android.model.SnackBarMessage import com.wire.android.ui.common.visbility.VisibilityState @@ -113,6 +114,7 @@ class ConversationMessagesViewModel( private val deleteMessage: DeleteMessageUseCase, private val isWireCellFeatureEnabled: IsWireCellsEnabledUseCase, private val networkStateObserver: NetworkStateObserver, + private val globalDataStore: GlobalDataStore, ) : ViewModel() { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() @@ -141,6 +143,7 @@ class ConversationMessagesViewModel( observeAudioPlayerState() observeAssetStatuses() observeNetworkAvailability() + observeMessageSwipeAction() } private fun observeNetworkAvailability() { @@ -153,6 +156,19 @@ class ConversationMessagesViewModel( } } + private fun observeMessageSwipeAction() { + viewModelScope.launch { + globalDataStore.messageSwipeRightActionFlow().collect { action -> + conversationViewState = conversationViewState.copy(messageSwipeRightAction = action) + } + } + viewModelScope.launch { + globalDataStore.messageSwipeLeftActionFlow().collect { action -> + conversationViewState = conversationViewState.copy(messageSwipeLeftAction = action) + } + } + } + val currentTimeInMillisFlow: Flow = flow { while (true) { delay(CURRENT_TIME_REFRESH_WINDOW_IN_MILLIS) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt index 4c4620bd4cc..9f772d823ff 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.home.conversations.messages import androidx.paging.PagingData import com.wire.android.media.audiomessage.PlayingAudioMessage +import com.wire.android.ui.home.conversations.messages.item.MessageSwipeAction import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.kalium.logic.data.message.MessageAssetStatus @@ -40,6 +41,8 @@ data class ConversationMessagesViewState( val isFetchingOlderMessages: Boolean = false, val hasMoreRemoteMessages: Boolean = true, val isNetworkAvailable: Boolean = false, + val messageSwipeRightAction: MessageSwipeAction = MessageSwipeAction.DEFAULT_RIGHT, + val messageSwipeLeftAction: MessageSwipeAction = MessageSwipeAction.DEFAULT_LEFT, ) sealed class DownloadedAssetDialogVisibilityState { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageSwipeAction.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageSwipeAction.kt new file mode 100644 index 00000000000..075ddb10cc2 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageSwipeAction.kt @@ -0,0 +1,51 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.messages.item + +import com.wire.android.ui.home.conversations.model.UIMessage + +enum class MessageSwipeAction { + REPLY, + REACT, + DETAILS; + + companion object { + val DEFAULT_RIGHT = REPLY + val DEFAULT_LEFT = REACT + + fun fromStoredValue(value: String?, defaultValue: MessageSwipeAction): MessageSwipeAction = + entries.firstOrNull { it.name == value } ?: defaultValue + } +} + +internal fun MessageSwipeAction.toSwipeAction( + message: UIMessage.Regular, + onSwipedToReply: (UIMessage.Regular) -> Unit, + onSwipedToReact: (UIMessage.Regular) -> Unit, + onSwipedToDetails: (UIMessage.Regular) -> Unit, +): SwipeAction? = + when (this) { + MessageSwipeAction.REPLY -> SwipeAction(iconResId()) { onSwipedToReply(message) } + .takeIf { message.isReplyable } + + MessageSwipeAction.REACT -> SwipeAction(iconResId()) { onSwipedToReact(message) } + .takeIf { message.isReactionAllowed } + + MessageSwipeAction.DETAILS -> SwipeAction(iconResId()) { onSwipedToDetails(message) } + } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt index 9208d3b87fb..9225a18666a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt @@ -45,13 +45,14 @@ import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import kotlin.math.absoluteValue import kotlin.math.min +import com.wire.android.ui.common.R as commonR @Stable sealed interface SwipeableMessageConfiguration { data object NotSwipeable : SwipeableMessageConfiguration class Swipeable( - val onSwipedRight: (() -> Unit)? = null, - val onSwipedLeft: (() -> Unit)? = null, + val onSwipedRight: SwipeAction? = null, + val onSwipedLeft: SwipeAction? = null, ) : SwipeableMessageConfiguration } @@ -78,22 +79,19 @@ internal fun SwipeableMessageBox( SwipeableBox( messageStyle = messageStyle, modifier = modifier, - onSwipeRight = swipeConfiguration?.onSwipedRight?.let { - SwipeAction( - icon = R.drawable.ic_reply, - action = it, - ) - }, - onSwipeLeft = swipeConfiguration?.onSwipedLeft?.let { - SwipeAction( - icon = R.drawable.ic_react, - action = it, - ) - }, + onSwipeRight = swipeConfiguration?.onSwipedRight, + onSwipeLeft = swipeConfiguration?.onSwipedLeft, content = content, ) } +fun MessageSwipeAction.iconResId(): Int = + when (this) { + MessageSwipeAction.REPLY -> R.drawable.ic_reply + MessageSwipeAction.REACT -> R.drawable.ic_react + MessageSwipeAction.DETAILS -> commonR.drawable.ic_info + } + @Suppress("CyclomaticComplexMethod") @OptIn(ExperimentalFoundationApi::class) @Composable diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationScreen.kt index 36ea0145659..a5ed34cd96c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationScreen.kt @@ -50,6 +50,7 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.home.conversations.details.options.ArrowType import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsItem +import com.wire.android.ui.home.conversations.messages.item.MessageSwipeAction import com.wire.android.ui.common.rowitem.SectionHeader import com.wire.android.ui.home.settings.SwitchState import com.wire.android.ui.theme.ThemeData @@ -73,6 +74,8 @@ fun CustomizationScreen( onThemeOptionChanged = viewModel::selectThemeOption, onBackPressed = navigator::navigateBack, onEnterToSendClicked = viewModel::selectPressEnterToSendOption, + onMessageSwipeRightActionChanged = viewModel::selectMessageSwipeRightAction, + onMessageSwipeLeftActionChanged = viewModel::selectMessageSwipeLeftAction, ) } @@ -82,6 +85,8 @@ fun CustomizationScreenContent( onThemeOptionChanged: (ThemeOption) -> Unit, onBackPressed: () -> Unit, onEnterToSendClicked: (Boolean) -> Unit, + onMessageSwipeRightActionChanged: (MessageSwipeAction) -> Unit, + onMessageSwipeLeftActionChanged: (MessageSwipeAction) -> Unit, modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState() ) { @@ -114,6 +119,10 @@ fun CustomizationScreenContent( CustomizationOptionsContent( enterToSendState = state.pressEnterToSentState, enterToSendClicked = onEnterToSendClicked, + selectedMessageSwipeRightAction = state.messageSwipeRightAction, + selectedMessageSwipeLeftAction = state.messageSwipeLeftAction, + onMessageSwipeRightActionChanged = onMessageSwipeRightActionChanged, + onMessageSwipeLeftActionChanged = onMessageSwipeLeftActionChanged, ) } } @@ -124,6 +133,10 @@ fun CustomizationScreenContent( fun CustomizationOptionsContent( enterToSendState: Boolean, enterToSendClicked: (Boolean) -> Unit, + selectedMessageSwipeRightAction: MessageSwipeAction, + selectedMessageSwipeLeftAction: MessageSwipeAction, + onMessageSwipeRightActionChanged: (MessageSwipeAction) -> Unit, + onMessageSwipeLeftActionChanged: (MessageSwipeAction) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -137,6 +150,79 @@ fun CustomizationOptionsContent( arrowType = ArrowType.NONE, subtitle = stringResource(id = R.string.press_enter_to_send_text) ) + SectionHeader(stringResource(R.string.customization_swipe_actions_header_title)) + Text( + text = stringResource(R.string.customization_swipe_right_title), + style = MaterialTheme.wireTypography.label01, + color = MaterialTheme.wireColorScheme.secondaryText, + modifier = Modifier.padding( + start = dimensions().spacing16x, + top = dimensions().spacing8x, + bottom = dimensions().spacing4x + ) + ) + MessageSwipeAction.entries.forEach { action -> + MessageSwipeActionOptionItem( + action = action, + selectedAction = selectedMessageSwipeRightAction, + onActionSelected = onMessageSwipeRightActionChanged, + ) + } + Text( + text = stringResource(R.string.customization_swipe_left_title), + style = MaterialTheme.wireTypography.label01, + color = MaterialTheme.wireColorScheme.secondaryText, + modifier = Modifier.padding( + start = dimensions().spacing16x, + top = dimensions().spacing16x, + bottom = dimensions().spacing4x + ) + ) + MessageSwipeAction.entries.forEach { action -> + MessageSwipeActionOptionItem( + action = action, + selectedAction = selectedMessageSwipeLeftAction, + onActionSelected = onMessageSwipeLeftActionChanged, + ) + } + } +} + +@Composable +private fun MessageSwipeActionOptionItem( + action: MessageSwipeAction, + selectedAction: MessageSwipeAction, + onActionSelected: (MessageSwipeAction) -> Unit, + modifier: Modifier = Modifier, +) { + val isSelected = action == selectedAction + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = dimensions().spacing1x) + .selectable( + selected = isSelected, + role = Role.RadioButton, + onClick = { onActionSelected(action) } + ) + .background(color = MaterialTheme.wireColorScheme.surface), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = isSelected, onClick = null) + Column( + modifier = Modifier.padding(vertical = dimensions().spacing12x) + ) { + Text( + text = stringResource(action.titleResId()), + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground + ) + Text( + text = stringResource(action.descriptionResId()), + style = MaterialTheme.wireTypography.label01, + color = MaterialTheme.wireColorScheme.secondaryText + ) + } } } @@ -207,5 +293,21 @@ fun PreviewSettingsScreen() { {}, {}, {}, + {}, + {}, ) } + +private fun MessageSwipeAction.titleResId(): Int = + when (this) { + MessageSwipeAction.REPLY -> R.string.customization_swipe_action_reply_title + MessageSwipeAction.REACT -> R.string.customization_swipe_action_react_title + MessageSwipeAction.DETAILS -> R.string.customization_swipe_action_details_title + } + +private fun MessageSwipeAction.descriptionResId(): Int = + when (this) { + MessageSwipeAction.REPLY -> R.string.customization_swipe_action_reply_description + MessageSwipeAction.REACT -> R.string.customization_swipe_action_react_description + MessageSwipeAction.DETAILS -> R.string.customization_swipe_action_details_description + } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationState.kt index 21df95bc4b0..b9b218cd91b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationState.kt @@ -17,9 +17,12 @@ */ package com.wire.android.ui.home.settings.appearance +import com.wire.android.ui.home.conversations.messages.item.MessageSwipeAction import com.wire.android.ui.theme.ThemeOption data class CustomizationState( val selectedThemeOption: ThemeOption = ThemeOption.SYSTEM, val pressEnterToSentState: Boolean = false, + val messageSwipeRightAction: MessageSwipeAction = MessageSwipeAction.DEFAULT_RIGHT, + val messageSwipeLeftAction: MessageSwipeAction = MessageSwipeAction.DEFAULT_LEFT, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationViewModel.kt index ad764cfb0cf..82d7cdc5034 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationViewModel.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.datastore.GlobalDataStore +import com.wire.android.ui.home.conversations.messages.item.MessageSwipeAction import com.wire.android.ui.theme.ThemeOption import kotlinx.coroutines.launch import dev.zacsweers.metro.Inject @@ -33,6 +34,7 @@ class CustomizationViewModel @Inject constructor( init { observeThemeState() observePressEnterToSendState() + observeMessageSwipeActionState() } private fun observePressEnterToSendState() { viewModelScope.launch { @@ -44,11 +46,35 @@ class CustomizationViewModel @Inject constructor( globalDataStore.selectedThemeOptionFlow().collect { option -> state = state.copy(selectedThemeOption = option) } } } + private fun observeMessageSwipeActionState() { + viewModelScope.launch { + globalDataStore.messageSwipeRightActionFlow().collect { action -> state = state.copy(messageSwipeRightAction = action) } + } + viewModelScope.launch { + globalDataStore.messageSwipeLeftActionFlow().collect { action -> state = state.copy(messageSwipeLeftAction = action) } + } + } fun selectPressEnterToSendOption(option: Boolean) { viewModelScope.launch { globalDataStore.setEnterToSend(option) } } + fun selectMessageSwipeRightAction(action: MessageSwipeAction) { + viewModelScope.launch { + globalDataStore.setMessageSwipeRightAction(action) + if (action == state.messageSwipeLeftAction) { + globalDataStore.setMessageSwipeLeftAction(state.messageSwipeRightAction) + } + } + } + fun selectMessageSwipeLeftAction(action: MessageSwipeAction) { + viewModelScope.launch { + globalDataStore.setMessageSwipeLeftAction(action) + if (action == state.messageSwipeRightAction) { + globalDataStore.setMessageSwipeRightAction(state.messageSwipeLeftAction) + } + } + } fun selectThemeOption(option: ThemeOption) { viewModelScope.launch { globalDataStore.setThemeOption(option) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99b9479c8de..2fe03aac89a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1885,6 +1885,15 @@ In group conversations, the group admin can overwrite this setting. Press Enter to send When this is on, you can send messages with the Enter key on your keyboard. Options + Swipe actions + Swipe right + Swipe left + Reply + Start a reply to the message. + React + Open the reaction picker. + Message details + Open reactions and read receipts. Server endpoints configuration diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index 91ecc8cda8a..468aa86aa95 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -23,11 +23,13 @@ import androidx.paging.PagingData import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri +import com.wire.android.datastore.GlobalDataStore import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer.MessageIdWrapper import com.wire.android.media.audiomessage.PlayingAudioMessage +import com.wire.android.ui.home.conversations.messages.item.MessageSwipeAction import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage @@ -136,6 +138,9 @@ class ConversationMessagesViewModelArrangement { @MockK lateinit var networkStateObserver: NetworkStateObserver + @MockK + lateinit var globalDataStore: GlobalDataStore + private val viewModel: ConversationMessagesViewModel by lazy { ConversationMessagesViewModel( savedStateHandle, @@ -157,6 +162,7 @@ class ConversationMessagesViewModelArrangement { deleteMessage, isWireCellFeatureEnabled, networkStateObserver, + globalDataStore, ) } @@ -178,6 +184,8 @@ class ConversationMessagesViewModelArrangement { coEvery { observeAssetStatuses(any()) } returns flowOf(mapOf()) every { networkStateObserver.observeNetworkState() } returns networkState + every { globalDataStore.messageSwipeRightActionFlow() } returns flowOf(MessageSwipeAction.DEFAULT_RIGHT) + every { globalDataStore.messageSwipeLeftActionFlow() } returns flowOf(MessageSwipeAction.DEFAULT_LEFT) coEvery { conversationAudioMessagePlayer.audioSpeed } returns flowOf(AudioSpeed.NORMAL) coEvery { conversationAudioMessagePlayer.playingAudioMessageFlow } returns flowOf(PlayingAudioMessage.None) @@ -229,6 +237,14 @@ class ConversationMessagesViewModelArrangement { coEvery { conversationAudioMessagePlayer.playingAudioMessageFlow } returns playingAudioMessageFlow } + fun withMessageSwipeRightAction(action: MessageSwipeAction) = apply { + every { globalDataStore.messageSwipeRightActionFlow() } returns flowOf(action) + } + + fun withMessageSwipeLeftAction(action: MessageSwipeAction) = apply { + every { globalDataStore.messageSwipeLeftActionFlow() } returns flowOf(action) + } + suspend fun withPaginatedMessagesReturning(pagingDataFlow: PagingData) = apply { messagesFlow.emit(pagingDataFlow) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index d8bed4ba394..0686d117f40 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -60,6 +60,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.Path.Companion.toPath import com.wire.android.assertions.shouldBeEqualTo +import com.wire.android.ui.home.conversations.messages.item.MessageSwipeAction import com.wire.android.ui.home.conversations.messages.item.withOfflineIndicator import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -71,6 +72,20 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(NavigationTestExtension::class) class ConversationMessagesViewModelTest { + @Test + fun `given stored swipe actions, when observing conversation state, then should update state`() = runTest { + val (_, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withMessageSwipeRightAction(MessageSwipeAction.DETAILS) + .withMessageSwipeLeftAction(MessageSwipeAction.REPLY) + .arrange() + + advanceUntilIdle() + + assertEquals(MessageSwipeAction.DETAILS, viewModel.conversationViewState.messageSwipeRightAction) + assertEquals(MessageSwipeAction.REPLY, viewModel.conversationViewState.messageSwipeLeftAction) + } + @Test fun `given an message ID, when downloading or fetching into internal storage, then should get message details by ID`() = runTest { val message = TestMessage.ASSET_MESSAGE diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageSwipeActionTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageSwipeActionTest.kt new file mode 100644 index 00000000000..6ba44e0b8df --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageSwipeActionTest.kt @@ -0,0 +1,134 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.messages.item + +import com.wire.android.ui.home.conversations.mock.mockMessageWithText +import com.wire.android.ui.home.conversations.model.MessageFlowStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MessageSwipeActionTest { + + @Test + fun `given stored value is null, when parsing swipe action, then should return default value`() { + val result = MessageSwipeAction.fromStoredValue(null, MessageSwipeAction.DEFAULT_RIGHT) + + assertEquals(MessageSwipeAction.DEFAULT_RIGHT, result) + } + + @Test + fun `given stored value is unknown, when parsing swipe action, then should return default value`() { + val result = MessageSwipeAction.fromStoredValue("STAR", MessageSwipeAction.DEFAULT_LEFT) + + assertEquals(MessageSwipeAction.DEFAULT_LEFT, result) + } + + @Test + fun `given stored value is known, when parsing swipe action, then should return matching value`() { + val result = MessageSwipeAction.fromStoredValue(MessageSwipeAction.DETAILS.name, MessageSwipeAction.DEFAULT_LEFT) + + assertEquals(MessageSwipeAction.DETAILS, result) + } + + @Test + fun `given message is replyable, when mapping reply swipe action, then should return action`() { + var wasReplyClicked = false + + val swipeAction = MessageSwipeAction.REPLY.toSwipeAction( + message = mockMessageWithText, + onSwipedToReply = { wasReplyClicked = true }, + onSwipedToReact = {}, + onSwipedToDetails = {}, + ) + + assertNotNull(swipeAction) + swipeAction?.action?.invoke() + assertTrue(wasReplyClicked) + } + + @Test + fun `given message is not replyable, when mapping reply swipe action, then should return null`() { + val swipeAction = MessageSwipeAction.REPLY.toSwipeAction( + message = pendingMessage, + onSwipedToReply = {}, + onSwipedToReact = {}, + onSwipedToDetails = {}, + ) + + assertNull(swipeAction) + } + + @Test + fun `given reaction is allowed, when mapping react swipe action, then should return action`() { + var wasReactClicked = false + + val swipeAction = MessageSwipeAction.REACT.toSwipeAction( + message = mockMessageWithText, + onSwipedToReply = {}, + onSwipedToReact = { wasReactClicked = true }, + onSwipedToDetails = {}, + ) + + assertNotNull(swipeAction) + swipeAction?.action?.invoke() + assertTrue(wasReactClicked) + } + + @Test + fun `given reaction is not allowed, when mapping react swipe action, then should return null`() { + val swipeAction = MessageSwipeAction.REACT.toSwipeAction( + message = pendingMessage, + onSwipedToReply = {}, + onSwipedToReact = {}, + onSwipedToDetails = {}, + ) + + assertNull(swipeAction) + } + + @Test + fun `given message is not replyable or reactable, when mapping details swipe action, then should return action`() { + var wasDetailsClicked = false + + val swipeAction = MessageSwipeAction.DETAILS.toSwipeAction( + message = pendingMessage, + onSwipedToReply = {}, + onSwipedToReact = {}, + onSwipedToDetails = { wasDetailsClicked = true }, + ) + + assertFalse(pendingMessage.isReplyable) + assertFalse(pendingMessage.isReactionAllowed) + assertNotNull(swipeAction) + swipeAction?.action?.invoke() + assertTrue(wasDetailsClicked) + } + + private val pendingMessage = mockMessageWithText.copy( + header = mockMessageWithText.header.copy( + messageStatus = mockMessageWithText.header.messageStatus.copy( + flowStatus = MessageFlowStatus.Sending + ) + ) + ) +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModelTest.kt index eb2761c3002..36a93248464 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModelTest.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.settings.appearance import com.wire.android.config.CoroutineTestExtension import com.wire.android.datastore.GlobalDataStore +import com.wire.android.ui.home.conversations.messages.item.MessageSwipeAction import com.wire.android.ui.theme.ThemeOption import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -27,7 +28,9 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -56,6 +59,66 @@ class AppearanceViewModelTest { coVerify(exactly = 1) { arrangement.globalDataStore.setEnterToSend(false) } } + @Test + fun `given stored swipe actions, when observing settings state, then should update state`() = runTest { + val (_, viewModel) = Arrangement() + .withEnterToSend(flowOf(true)) + .withMessageSwipeRightAction(MessageSwipeAction.DETAILS) + .withMessageSwipeLeftAction(MessageSwipeAction.REPLY) + .arrange() + + advanceUntilIdle() + + assertEquals(MessageSwipeAction.DETAILS, viewModel.state.messageSwipeRightAction) + assertEquals(MessageSwipeAction.REPLY, viewModel.state.messageSwipeLeftAction) + } + + @Test + fun `given swipe right action, when changing it, then should update global data store`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withEnterToSend(flowOf(true)) + .arrange() + + viewModel.selectMessageSwipeRightAction(MessageSwipeAction.DETAILS) + + coVerify(exactly = 1) { arrangement.globalDataStore.setMessageSwipeRightAction(MessageSwipeAction.DETAILS) } + } + + @Test + fun `given swipe left action, when changing it, then should update global data store`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withEnterToSend(flowOf(true)) + .arrange() + + viewModel.selectMessageSwipeLeftAction(MessageSwipeAction.REPLY) + + coVerify(exactly = 1) { arrangement.globalDataStore.setMessageSwipeLeftAction(MessageSwipeAction.REPLY) } + } + + @Test + fun `given swipe right action already used on left, when changing right, then should swap actions`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withEnterToSend(flowOf(true)) + .arrange() + + viewModel.selectMessageSwipeRightAction(MessageSwipeAction.DEFAULT_LEFT) + + coVerify(exactly = 1) { arrangement.globalDataStore.setMessageSwipeRightAction(MessageSwipeAction.DEFAULT_LEFT) } + coVerify(exactly = 1) { arrangement.globalDataStore.setMessageSwipeLeftAction(MessageSwipeAction.DEFAULT_RIGHT) } + } + + @Test + fun `given swipe left action already used on right, when changing left, then should swap actions`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withEnterToSend(flowOf(true)) + .arrange() + + viewModel.selectMessageSwipeLeftAction(MessageSwipeAction.DEFAULT_RIGHT) + + coVerify(exactly = 1) { arrangement.globalDataStore.setMessageSwipeLeftAction(MessageSwipeAction.DEFAULT_RIGHT) } + coVerify(exactly = 1) { arrangement.globalDataStore.setMessageSwipeRightAction(MessageSwipeAction.DEFAULT_LEFT) } + } + private class Arrangement { @MockK lateinit var globalDataStore: GlobalDataStore @@ -63,13 +126,25 @@ class AppearanceViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) coEvery { globalDataStore.setThemeOption(any()) } returns Unit + coEvery { globalDataStore.setMessageSwipeRightAction(any()) } returns Unit + coEvery { globalDataStore.setMessageSwipeLeftAction(any()) } returns Unit every { globalDataStore.selectedThemeOptionFlow() } returns flowOf(ThemeOption.DARK) + every { globalDataStore.messageSwipeRightActionFlow() } returns flowOf(MessageSwipeAction.DEFAULT_RIGHT) + every { globalDataStore.messageSwipeLeftActionFlow() } returns flowOf(MessageSwipeAction.DEFAULT_LEFT) } fun withEnterToSend(result: Flow) = apply { every { globalDataStore.enterToSendFlow() } returns result } + fun withMessageSwipeRightAction(action: MessageSwipeAction) = apply { + every { globalDataStore.messageSwipeRightActionFlow() } returns flowOf(action) + } + + fun withMessageSwipeLeftAction(action: MessageSwipeAction) = apply { + every { globalDataStore.messageSwipeLeftActionFlow() } returns flowOf(action) + } + fun arrange() = this to CustomizationViewModel(globalDataStore) } }