diff --git a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt index fb7ca3929a0..951ca60ceed 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -49,7 +49,7 @@ fun ConversationDetailsWithEvents.toConversationItem( groupName = conversationDetails.conversation.name.orEmpty(), conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, - isLegalHold = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), + showLegalHoldIndicator = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), lastMessageContent = lastMessage.toUIPreview(unreadEventCount), badgeEventType = parseConversationEventType( mutedStatus = conversationDetails.conversation.mutedStatus, @@ -83,7 +83,7 @@ fun ConversationDetailsWithEvents.toConversationItem( ), conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, - isLegalHold = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), + showLegalHoldIndicator = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), lastMessageContent = lastMessage.toUIPreview(unreadEventCount), badgeEventType = parsePrivateConversationEventType( conversationDetails.otherUser.connectionStatus, diff --git a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt index 24bb43d7d2f..cb0680e6e33 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt @@ -148,7 +148,7 @@ class MessageMapper @Inject constructor( is SelfUser, null -> Membership.None }, connectionState = getConnectionState(sender), - isLegalHold = sender?.isUnderLegalHold == true, + showLegalHoldIndicator = sender?.isUnderLegalHold == true, messageTime = MessageTime(message.date), messageStatus = getMessageStatus(message), messageId = message.id, diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index f9877a1fa6d..ec577f2c221 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -177,6 +177,8 @@ fun OngoingCallScreen( clearVideoPreview = sharedCallingViewModel::clearVideoPreview, onCollapse = onCollapse, requestVideoStreams = ongoingCallViewModel::requestVideoStreams, + onSelectedParticipant = ongoingCallViewModel::onSelectedParticipant, + selectedParticipantForFullScreen = ongoingCallViewModel.selectedParticipant, hideDoubleTapToast = ongoingCallViewModel::hideDoubleTapToast, onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, participants = sharedCallingViewModel.participantsState, @@ -289,6 +291,8 @@ private fun OngoingCallContent( hideDoubleTapToast: () -> Unit, onCameraPermissionPermanentlyDenied: () -> Unit, requestVideoStreams: (participants: List) -> Unit, + onSelectedParticipant: (selectedParticipant: SelectedParticipant) -> Unit, + selectedParticipantForFullScreen: SelectedParticipant, participants: PersistentList, inPictureInPictureMode: Boolean, currentUserId: UserId, @@ -303,7 +307,6 @@ private fun OngoingCallContent( ) var shouldOpenFullScreen by remember { mutableStateOf(false) } - var selectedParticipantForFullScreen by remember { mutableStateOf(SelectedParticipant()) } WireBottomSheetScaffold( sheetDragHandle = null, @@ -391,11 +394,14 @@ private fun OngoingCallContent( selectedParticipant = selectedParticipantForFullScreen, height = this@BoxWithConstraints.maxHeight - dimensions().spacing4x, closeFullScreen = { + onSelectedParticipant(SelectedParticipant()) shouldOpenFullScreen = !shouldOpenFullScreen }, onBackButtonClicked = { + onSelectedParticipant(SelectedParticipant()) shouldOpenFullScreen = !shouldOpenFullScreen }, + requestVideoStreams = requestVideoStreams, setVideoPreview = setVideoPreview, clearVideoPreview = clearVideoPreview, participants = participants @@ -412,7 +418,7 @@ private fun OngoingCallContent( requestVideoStreams = requestVideoStreams, currentUserId = currentUserId, onDoubleTap = { selectedParticipant -> - selectedParticipantForFullScreen = selectedParticipant + onSelectedParticipant(selectedParticipant) shouldOpenFullScreen = !shouldOpenFullScreen }, ) @@ -580,6 +586,8 @@ fun PreviewOngoingCallContent(participants: PersistentList) { participants = participants, inPictureInPictureMode = false, currentUserId = UserId("userId", "domain"), + onSelectedParticipant = {}, + selectedParticipantForFullScreen = SelectedParticipant(), ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt index 8e07e40bc1b..84c6e1a57d1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt @@ -28,8 +28,10 @@ import com.wire.android.appLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.CurrentAccount import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient +import com.wire.kalium.logic.data.call.CallQuality import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId @@ -63,6 +65,8 @@ class OngoingCallViewModel @AssistedInject constructor( var state by mutableStateOf(OngoingCallState()) private set + var selectedParticipant by mutableStateOf(SelectedParticipant()) + private set init { viewModelScope.launch { @@ -124,7 +128,11 @@ class OngoingCallViewModel @AssistedInject constructor( .also { if (it.isNotEmpty()) { val clients: List = it.map { uiParticipant -> - CallClient(uiParticipant.id.toString(), uiParticipant.clientId) + CallClient( + userId = uiParticipant.id.toString(), + clientId = uiParticipant.clientId, + quality = mapQualityStream(uiParticipant) + ) } requestVideoStreams(conversationId, clients) } @@ -132,12 +140,20 @@ class OngoingCallViewModel @AssistedInject constructor( } } + private fun mapQualityStream(uiParticipant: UICallParticipant): CallQuality { + return if (uiParticipant.clientId == selectedParticipant.clientId) { + CallQuality.HIGH + } else { + CallQuality.LOW + } + } + private fun startDoubleTapToastDisplayCountDown() { doubleTapIndicatorCountDownTimer?.cancel() doubleTapIndicatorCountDownTimer = object : CountDownTimer(DOUBLE_TAP_TOAST_DISPLAY_TIME, COUNT_DOWN_INTERVAL) { override fun onTick(p0: Long) { - appLogger.i("startDoubleTapToastDisplayCountDown: $p0") + appLogger.d("$TAG - startDoubleTapToastDisplayCountDown: $p0") } override fun onFinish() { @@ -171,10 +187,16 @@ class OngoingCallViewModel @AssistedInject constructor( } } + fun onSelectedParticipant(selectedParticipant: SelectedParticipant) { + appLogger.d("$TAG - Selected participant: ${selectedParticipant.toLogString()}") + this.selectedParticipant = selectedParticipant + } + companion object { const val DOUBLE_TAP_TOAST_DISPLAY_TIME = 7000L const val COUNT_DOWN_INTERVAL = 1000L const val DELAY_TO_SHOW_DOUBLE_TAP_TOAST = 500L + const val TAG = "OngoingCallViewModel" } @AssistedFactory diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt index 7edbd0d7ba3..cee64f83032 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt @@ -60,6 +60,7 @@ fun FullScreenTile( closeFullScreen: (offset: Offset) -> Unit, onBackButtonClicked: () -> Unit, setVideoPreview: (View) -> Unit, + requestVideoStreams: (participants: List) -> Unit, clearVideoPreview: () -> Unit, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, @@ -119,6 +120,10 @@ fun FullScreenTile( } ) } + + LaunchedEffect(selectedParticipant.userId) { + requestVideoStreams(listOf(it)) + } } } @@ -139,6 +144,7 @@ fun PreviewFullScreenTile() = WireTheme { closeFullScreen = {}, onBackButtonClicked = {}, setVideoPreview = {}, + requestVideoStreams = {}, clearVideoPreview = {}, participants = participants, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt index 95238a33c27..af1a1d87f45 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt @@ -17,10 +17,16 @@ */ package com.wire.android.ui.calling.ongoing.fullscreen +import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.data.user.UserId data class SelectedParticipant( val userId: UserId = UserId("", ""), val clientId: String = "", val isSelfUser: Boolean = false -) +) { + + fun toLogString(): String { + return "SelectedParticipant(userId=${userId.toLogString()}, clientId=${clientId.obfuscateId()}, isSelfUser=$isSelfUser)" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt index 0e48a9ac9a7..0279e37367b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt @@ -78,7 +78,7 @@ fun rememberConversationSheetState( protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isUnderLegalHold = isLegalHold + isUnderLegalHold = showLegalHoldIndicator ) } } @@ -102,7 +102,7 @@ fun rememberConversationSheetState( protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isUnderLegalHold = isLegalHold + isUnderLegalHold = showLegalHoldIndicator ) } } @@ -122,7 +122,7 @@ fun rememberConversationSheetState( protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isUnderLegalHold = isLegalHold + isUnderLegalHold = showLegalHoldIndicator ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt index af7d5553dde..1f19379769b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt @@ -69,7 +69,7 @@ fun CodeTextField( maxHorizontalSpacing = maxHorizontalSpacing, horizontalAlignment = horizontalAlignment, modifier = modifier, - innerBasicTextField = { decorator, textFieldModifier -> + innerBasicTextField = { decorator, textFieldModifier, _ -> BasicTextField( state = textState, textStyle = textStyle, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt index d05c9acf38b..60bcadf6e54 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt @@ -105,6 +105,7 @@ internal fun CodeTextFieldLayout( } }, textFieldModifier = Modifier, + decorationBox = {} ) val bottomText = when { state is WireTextFieldState.Error && state.errorText != null -> state.errorText diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt new file mode 100644 index 00000000000..9f34070fc6f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt @@ -0,0 +1,50 @@ +/* + * Wire + * Copyright (C) 2024 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.common.textfield + +import androidx.compose.ui.text.TextRange + +object MentionDeletionHandler { + @Suppress("ReturnCount") + fun handle( + oldText: String, + newText: String, + oldSelection: TextRange, + mentions: List + ): String { + if (oldText == newText) { + // No change in text, only cursor movement, return as is + return oldText + } + for (mention in mentions) { + // Find the start position of the mention in the text + val mentionStart = oldText.indexOf(mention) + + if (mentionStart == -1) continue + + val mentionEnd = mentionStart + mention.length + + // Check if the selection (i.e., user's cursor position) is inside the mention's range + if (oldSelection.start in mentionStart + 1..mentionEnd || oldSelection.end in mentionStart + 1..mentionEnd) { + // If the user is deleting inside the mention, remove the entire mention + return oldText.removeRange(mentionStart, mentionEnd) + } + } + return newText + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt new file mode 100644 index 00000000000..ebd399766e1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2024 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.common.textfield + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle +import com.wire.android.ui.home.conversations.model.UIMention + +class MentionVisualTransformation( + val color: Color, + val mentions: List +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val styledText = buildAnnotatedString { + var lastIndex = 0 + text.takeIf { it.isNotEmpty() }?.let { + mentions.forEach { mention -> + // Append the text before the mention + append(text.subSequence(lastIndex, mention.start)) + // Apply the style to the mention + withStyle(style = SpanStyle(color = color, fontWeight = FontWeight.Bold)) { + append(text.subSequence(mention.start, mention.start + mention.length)) + } + lastIndex = mention.start + mention.length + } + } + // Append the remaining text after the last mention + append(text.subSequence(lastIndex, text.length)) + } + return TransformedText( + text = styledText, + offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = offset + override fun transformedToOriginal(offset: Int): Int = offset + } + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt index 87626e8f3aa..c87cf1fdb2f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt @@ -110,7 +110,7 @@ fun WirePasswordTextField( modifier = modifier.then(autoFillModifier(autoFillType, textState::setTextAndPlaceCursorAtEnd)), testTag = testTag, onTap = onTap, - innerBasicTextField = { decorator, textFieldModifier -> + innerBasicTextField = { decorator, textFieldModifier, _ -> BasicSecureTextField( state = textState, textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index 0bf843388d9..4ee35be59bb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.KeyboardActionHandler @@ -42,6 +43,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,10 +56,13 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @@ -134,7 +140,7 @@ internal fun WireTextField( ), onTap = onTap, testTag = testTag, - innerBasicTextField = { decorator, textFieldModifier -> + innerBasicTextField = { decorator, textFieldModifier, _ -> BasicTextField( state = textState, textStyle = textStyle.copy( @@ -163,6 +169,107 @@ internal fun WireTextField( ) } +@Composable +internal fun WireTextField( + textFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + mentions: List = emptyList(), + placeholderText: String? = null, + labelText: String? = null, + labelMandatoryIcon: Boolean = false, + descriptionText: String? = null, + semanticDescription: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + state: WireTextFieldState = WireTextFieldState.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.DefaultText, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + textStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderAlignment: Alignment.Horizontal = Alignment.Start, + inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), + colors: WireTextFieldColors = wireTextFieldColors(), + onSelectedLineIndexChanged: (Int) -> Unit = { }, + onLineBottomYCoordinateChanged: (Float) -> Unit = { }, + onTap: ((Offset) -> Unit)? = null, + testTag: String = String.EMPTY +) { + WireTextFieldLayout( + modifier = modifier, + shouldShowPlaceholder = textFieldValue.value.text.isEmpty(), + placeholderText = placeholderText, + labelText = labelText, + labelMandatoryIcon = labelMandatoryIcon, + descriptionText = descriptionText, + semanticDescription = semanticDescription, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + state = state, + interactionSource = interactionSource, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + shape = shape, + colors = colors, + onTap = onTap, + testTag = testTag, + innerBasicTextField = { _, textFieldModifier, decoratorBox -> + BasicTextField( + value = textFieldValue.value, + onValueChange = { newText -> + val mentionsByName = mentions.map { it.handler } + val updatedText = + MentionDeletionHandler.handle( + textFieldValue.value.text, + newText.text, + textFieldValue.value.selection, + mentionsByName + ) + onValueChange(TextFieldValue(updatedText, newText.selection)) + }, + textStyle = textStyle.copy( + color = colors.textColor(state = state).value, + textDirection = TextDirection.ContentOrLtr + ), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + readOnly = state is WireTextFieldState.ReadOnly, + enabled = state !is WireTextFieldState.Disabled, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + interactionSource = interactionSource, + modifier = textFieldModifier, + decorationBox = decoratorBox, + onTextLayout = onTextLayout( + textFieldValue, + onSelectedLineIndexChanged, + onLineBottomYCoordinateChanged + ), + visualTransformation = MentionVisualTransformation(colorsScheme().primary, mentions), + ) + } + ) +} + +private fun onTextLayout( + textFieldValue: State, + onSelectedLineIndexChanged: (Int) -> Unit = { }, + onLineBottomYCoordinateChanged: (Float) -> Unit = { }, +): (TextLayoutResult) -> Unit = { + val lineOfText = it.getLineForOffset(textFieldValue.value.selection.end) + val bottomYCoordinate = it.getLineBottom(lineOfText) + onSelectedLineIndexChanged(lineOfText) + onLineBottomYCoordinateChanged(bottomYCoordinate) +} + private fun onTextLayout( state: TextFieldState, onSelectedLineIndexChanged: (Int) -> Unit = { }, @@ -203,6 +310,16 @@ private fun KeyboardOptions.Companion.defaultEmail(imeAction: ImeAction): Keyboa ) } +@PreviewMultipleThemes +@Composable +fun PreviewWireTextFieldWithTextFieldValue() = WireTheme { + WireTextField( + modifier = Modifier.padding(16.dp), + textFieldValue = remember { mutableStateOf(TextFieldValue("text")) }, + onValueChange = {} + ) +} + @PreviewMultipleThemes @Composable fun PreviewWireTextField() = WireTheme { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt index b99c98af954..d93d1e73309 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt @@ -111,6 +111,21 @@ internal fun WireTextFieldLayout( onTap = onTap, ) }, + decorationBox = { innerTextField -> + InnerTextLayout( + innerTextField = innerTextField, + shouldShowPlaceholder = shouldShowPlaceholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + placeholderText = placeholderText, + style = state, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + colors = colors, + onTap = onTap, + ) + }, textFieldModifier = Modifier .fillMaxWidth() .background(color = colors.backgroundColor(state).value, shape = shape) @@ -218,5 +233,9 @@ private fun Alignment.Horizontal.toAlignment(): Alignment = Alignment { size, sp fun interface InnerBasicTextFieldBuilder { @Composable - fun Build(decorator: TextFieldDecorator, textFieldModifier: Modifier) + fun Build( + decorator: TextFieldDecorator, + textFieldModifier: Modifier, + decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit + ) } 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 4677a8fae12..0087bc2f6eb 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 @@ -295,7 +295,7 @@ fun ConversationScreen( LaunchedEffect(messageDraftViewModel.state.value.quotedMessageId) { val compositionState = messageDraftViewModel.state.value if (compositionState.quotedMessage != null) { - messageComposerStateHolder.messageCompositionHolder.updateQuote(compositionState.quotedMessage) + messageComposerStateHolder.messageCompositionHolder.value.updateQuote(compositionState.quotedMessage) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index 42bbe66926d..d548d2a7b0b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -111,7 +111,6 @@ import kotlin.math.absoluteValue import kotlin.math.min // TODO: a definite candidate for a refactor and cleanup -@OptIn(ExperimentalFoundationApi::class) @Suppress("ComplexMethod") @Composable fun RegularMessageItem( @@ -513,7 +512,7 @@ private fun MessageAuthorRow(messageHeader: MessageHeader) { startPadding = dimensions().spacing6x, isDeleted = isSenderDeleted ) - if (isLegalHold) { + if (showLegalHoldIndicator) { LegalHoldIndicator(modifier = Modifier.padding(start = dimensions().spacing6x)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt index 7b0461a57bc..b995681aed8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt @@ -65,7 +65,7 @@ val mockMessageTime = MessageTime(Instant.fromEpochSeconds(MOCK_TIME_IN_SECONDS) val mockHeader = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -307,7 +307,7 @@ fun mockAssetMessage(assetId: String = "asset1", messageId: String = "msg1") = U header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -337,7 +337,7 @@ fun mockAssetAudioMessage(assetId: String = "asset1", messageId: String = "msg1" header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -396,7 +396,7 @@ fun mockedImageUIMessage( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = messageStatus, messageId = messageId, @@ -417,7 +417,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -447,7 +447,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Delivered, isDeleted = true, @@ -468,7 +468,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -490,7 +490,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -512,7 +512,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Delivered, @@ -543,7 +543,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -565,7 +565,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt index c3f70e439d8..bd507cdf380 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt @@ -127,7 +127,7 @@ sealed interface UIMessage { data class MessageHeader( val username: UIText, val membership: Membership, - val isLegalHold: Boolean, + val showLegalHoldIndicator: Boolean, val messageTime: MessageTime, val messageStatus: MessageStatus, val messageId: String, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt index 7c7d830bd9a..ebb17ba5ea8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt @@ -28,10 +28,12 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import com.wire.android.R +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.EMPTY import com.wire.android.util.QueryMatchExtractor +import com.wire.android.util.ui.PreviewMultipleThemes @Composable fun HighlightName( @@ -93,3 +95,14 @@ fun HighlightName( @Composable private fun String.isUnknownUser() = this == stringResource(id = R.string.username_unavailable_label) + +@PreviewMultipleThemes +@Composable +fun PreviewHighlightName() { + WireTheme { + HighlightName( + name = "John Doe", + searchQuery = "John" + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index cbea3a7fcdd..2b5c4b66748 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.insertSeparators +import androidx.paging.map import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.di.CurrentAccount @@ -64,6 +65,8 @@ import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMet import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase +import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser +import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import com.wire.kalium.logic.feature.team.DeleteTeamConversationUseCase import com.wire.kalium.logic.feature.team.Result @@ -78,6 +81,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow @@ -121,6 +125,7 @@ class ConversationListViewModelPreview( @HiltViewModel(assistedFactory = ConversationListViewModelImpl.Factory::class) class ConversationListViewModelImpl @AssistedInject constructor( @Assisted val conversationsSource: ConversationsSource, + @Assisted private val usePagination: Boolean = BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED, dispatcher: DispatcherProvider, private val updateConversationMutedStatus: UpdateConversationMutedStatusUseCase, private val getConversationsPaginated: GetConversationsFromSearchUseCase, @@ -133,6 +138,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( private val refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, private val refreshConversationsWithoutMetadata: RefreshConversationsWithoutMetadataUseCase, private val updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase, + private val observeLegalHoldStateForSelfUser: ObserveLegalHoldStateForSelfUserUseCase, @CurrentAccount val currentAccount: UserId, private val wireSessionImageLoader: WireSessionImageLoader, private val userTypeMapper: UserTypeMapper, @@ -140,7 +146,10 @@ class ConversationListViewModelImpl @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(conversationsSource: ConversationsSource): ConversationListViewModelImpl + fun create( + conversationsSource: ConversationsSource, + usePagination: Boolean = BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED, + ): ConversationListViewModelImpl } private val _infoMessage = MutableSharedFlow() @@ -173,7 +182,11 @@ class ConversationListViewModelImpl @AssistedInject constructor( conversationFilter = conversationsSource.toFilter(), onlyInteractionEnabled = false, newActivitiesOnTop = containsNewActivitiesSection, - ).map { + ).combine(observeLegalHoldStateForSelfUser()) { conversations, selfUserLegalHoldStatus -> + conversations.map { + it.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus) + } + }.map { it.insertSeparators { before, after -> when { // do not add separators if the list shouldn't show conversations grouped into different folders @@ -200,7 +213,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( private var notPaginatedConversationListState by mutableStateOf(ConversationListState.NotPaginated()) override val conversationListState: ConversationListState - get() = if (BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED) { + get() = if (usePagination) { ConversationListState.Paginated( conversations = conversationsPaginatedFlow, domain = currentAccount.domain @@ -210,7 +223,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( } init { - if (!BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED) { + if (!usePagination) { viewModelScope.launch { searchQueryFlow .debounce { if (it.isEmpty()) 0L else DEFAULT_SEARCH_QUERY_DEBOUNCE } @@ -220,13 +233,13 @@ class ConversationListViewModelImpl @AssistedInject constructor( observeConversationListDetailsWithEvents( fromArchive = conversationsSource == ConversationsSource.ARCHIVE, conversationFilter = conversationsSource.toFilter() - ).map { - it.map { conversationDetails -> + ).combine(observeLegalHoldStateForSelfUser()) { conversations, selfUserLegalHoldStatus -> + conversations.map { conversationDetails -> conversationDetails.toConversationItem( wireSessionImageLoader = wireSessionImageLoader, userTypeMapper = userTypeMapper, searchQuery = searchQuery, - ) + ).hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus) } to searchQuery } } @@ -438,6 +451,19 @@ private fun ConversationsSource.toFilter(): ConversationFilter = when (this) { ConversationsSource.ONE_ON_ONE -> ConversationFilter.ONE_ON_ONE } +private fun ConversationItem.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus: LegalHoldStateForSelfUser) = + // if self user is under legal hold then we shouldn't show legal hold indicator next to every conversation + // the indication is shown in the header of the conversation list for self user in that case and it's enough + when (selfUserLegalHoldStatus) { + is LegalHoldStateForSelfUser.Enabled -> when (this) { + is ConversationItem.ConnectionConversation -> this.copy(showLegalHoldIndicator = false) + is ConversationItem.GroupConversation -> this.copy(showLegalHoldIndicator = false) + is ConversationItem.PrivateConversation -> this.copy(showLegalHoldIndicator = false) + } + + else -> this + } + @Suppress("ComplexMethod") private fun List.withFolders(source: ConversationsSource): Map> { return when (source) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index 44ac89e3f7a..9a8f216b91b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -159,7 +159,7 @@ private fun GeneralConversationItem( title = { ConversationTitle( name = groupName.ifEmpty { stringResource(id = R.string.member_name_deleted_label) }, - isLegalHold = conversation.isLegalHold, + showLegalHoldIndicator = conversation.showLegalHoldIndicator, searchQuery = searchQuery ) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt index 0382e16960e..1bbf7362869 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt @@ -38,7 +38,7 @@ fun ConversationTitle( name: String, searchQuery: String, modifier: Modifier = Modifier, - isLegalHold: Boolean = false, + showLegalHoldIndicator: Boolean = false, badges: @Composable () -> Unit = {} ) { Row( @@ -57,7 +57,7 @@ fun ConversationTitle( HighlightName(name = name, searchQuery = searchQuery) } badges() - if (isLegalHold) { + if (showLegalHoldIndicator) { Spacer(modifier = Modifier.width(6.dp)) LegalHoldIndicator() } @@ -67,5 +67,5 @@ fun ConversationTitle( @Preview(widthDp = 200) @Composable fun PreviewConversationTitle() { - ConversationTitle("very very loooooooooooong name", searchQuery = "test", isLegalHold = true) + ConversationTitle("very very loooooooooooong name", searchQuery = "test", showLegalHoldIndicator = true) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt index 0daf7203d99..c7ec40a8d7d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt @@ -39,7 +39,7 @@ fun UserLabel( with(userInfoLabel) { ConversationTitle( name = if (unavailable) stringResource(id = R.string.username_unavailable_label) else labelName, - isLegalHold = isLegalHold, + showLegalHoldIndicator = showLegalHoldIndicator, modifier = modifier, badges = { if (membership.hasLabel()) { @@ -54,7 +54,7 @@ fun UserLabel( data class UserInfoLabel( val labelName: String, - val isLegalHold: Boolean, + val showLegalHoldIndicator: Boolean, val membership: Membership, val unavailable: Boolean = false, val proteusVerificationStatus: Conversation.VerificationStatus? = null, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index 467805827d1..dbc5f36305c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -33,7 +33,7 @@ import com.wire.kalium.logic.data.user.type.isTeammate sealed class ConversationItem : ConversationFolderItem { abstract val conversationId: ConversationId abstract val mutedStatus: MutedConversationStatus - abstract val isLegalHold: Boolean + abstract val showLegalHoldIndicator: Boolean abstract val lastMessageContent: UILastMessageContent? abstract val badgeEventType: BadgeEventType abstract val teamId: TeamId? @@ -53,7 +53,7 @@ sealed class ConversationItem : ConversationFolderItem { val isSelfUserMember: Boolean = true, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, - override val isLegalHold: Boolean = false, + override val showLegalHoldIndicator: Boolean = false, override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val teamId: TeamId?, @@ -71,7 +71,7 @@ sealed class ConversationItem : ConversationFolderItem { val blockingState: BlockingState, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, - override val isLegalHold: Boolean = false, + override val showLegalHoldIndicator: Boolean = false, override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val teamId: TeamId?, @@ -87,7 +87,7 @@ sealed class ConversationItem : ConversationFolderItem { val conversationInfo: ConversationInfo, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, - override val isLegalHold: Boolean = false, + override val showLegalHoldIndicator: Boolean = false, override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val isArchived: Boolean = false, @@ -123,7 +123,7 @@ val OtherUser.BlockState: BlockingState fun ConversationItem.PrivateConversation.toUserInfoLabel() = UserInfoLabel( labelName = conversationInfo.name, - isLegalHold = isLegalHold, + showLegalHoldIndicator = showLegalHoldIndicator, membership = conversationInfo.membership, unavailable = conversationInfo.isSenderUnavailable, mlsVerificationStatus = mlsVerificationStatus, @@ -133,7 +133,7 @@ fun ConversationItem.PrivateConversation.toUserInfoLabel() = fun ConversationItem.ConnectionConversation.toUserInfoLabel() = UserInfoLabel( labelName = conversationInfo.name, - isLegalHold = isLegalHold, + showLegalHoldIndicator = showLegalHoldIndicator, membership = conversationInfo.membership, unavailable = conversationInfo.isSenderUnavailable ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt index 6cc8a51f698..39fc2d15e6f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt @@ -172,7 +172,7 @@ fun EnabledMessageComposer( membersToMention = messageComposerViewState.value.mentionSearchResult, searchQuery = messageComposerViewState.value.mentionSearchQuery, onMentionPicked = { pickedMention -> - messageCompositionHolder.addMention(pickedMention) + messageCompositionHolder.value.addMention(pickedMention) onClearMentionSearchResult() }, modifier = Modifier.align(Alignment.BottomCenter) @@ -205,14 +205,18 @@ fun EnabledMessageComposer( ActiveMessageComposerInput( conversationId = conversationId, messageComposition = messageComposition.value, - messageTextState = inputStateHolder.messageTextState, + messageTextFieldValue = inputStateHolder.messageTextFieldValue, + onValueChange = { + inputStateHolder.messageTextFieldValue.value = it + }, + mentions = (messageComposition.value.selectedMentions), isTextExpanded = inputStateHolder.isTextExpanded, inputType = messageCompositionInputStateHolder.inputType, focusRequester = messageCompositionInputStateHolder.focusRequester, onFocused = ::onInputFocused, onToggleInputSize = messageCompositionInputStateHolder::toggleInputSize, onTextCollapse = messageCompositionInputStateHolder::collapseText, - onCancelReply = messageCompositionHolder::clearReply, + onCancelReply = messageCompositionHolder.value::clearReply, onCancelEdit = ::cancelEdit, onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, onSendButtonClicked = onSendButtonClicked, @@ -274,7 +278,7 @@ fun EnabledMessageComposer( membersToMention = mentionSearchResult, searchQuery = messageComposerViewState.value.mentionSearchQuery, onMentionPicked = { - messageCompositionHolder.addMention(it) + messageCompositionHolder.value.addMention(it) onClearMentionSearchResult() } ) @@ -289,9 +293,9 @@ fun EnabledMessageComposer( attachmentsVisible = inputStateHolder.optionsVisible, isEditing = messageCompositionInputStateHolder.inputType is InputType.Editing, isMentionActive = messageComposerViewState.value.mentionSearchResult.isNotEmpty(), - onMentionButtonClicked = messageCompositionHolder::startMention, + onMentionButtonClicked = messageCompositionHolder.value::startMention, onOnSelfDeletingOptionClicked = onChangeSelfDeletionClicked, - onRichOptionButtonClicked = messageCompositionHolder::addOrRemoveMessageMarkdown, + onRichOptionButtonClicked = messageCompositionHolder.value::addOrRemoveMessageMarkdown, onPingOptionClicked = onPingOptionClicked, onAdditionalOptionsMenuClicked = { if (!hideRipple) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt index d122ba6c584..d5336197aea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt @@ -26,10 +26,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.wire.android.model.Clickable import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.home.conversations.mention.MemberItemToMention import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.home.newconversation.model.Contact +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme +import com.wire.kalium.logic.data.user.ConnectionState @Composable fun MembersMentionList( @@ -66,3 +69,35 @@ fun MembersMentionList( } } } + +@MultipleThemePreviews +@Composable +fun MembersMentionListPreview() { + WireTheme { + MembersMentionList( + membersToMention = listOf( + Contact( + id = "1", + domain = "domain", + name = "Marko Alonso", + handle = "john.doe", + label = "label", + membership = Membership.Admin, + connectionState = ConnectionState.ACCEPTED + ), + Contact( + id = "2", + domain = "domain", + name = "John Doe", + handle = "john.doe", + label = "label", + membership = Membership.Admin, + connectionState = ConnectionState.ACCEPTED + ) + ), + searchQuery = "John", + onMentionPicked = {}, + modifier = Modifier + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt index 92abe5a72e5..e16bda6fe63 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -48,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.R import com.wire.android.ui.common.TextWithLearnMore import com.wire.android.ui.common.banner.SecurityClassificationBannerForConversation @@ -134,10 +134,10 @@ fun MessageComposer( messageComposerStateHolder = messageComposerStateHolder, messageListContent = messageListContent, onSendButtonClicked = { - onSendMessageBundle(messageCompositionHolder.toMessageBundle(conversationId)) + onSendMessageBundle(messageCompositionHolder.value.toMessageBundle(conversationId)) onClearMentionSearchResult() clearMessage() - messageCompositionHolder.onClearDraft() + messageCompositionHolder.value.onClearDraft() }, onPingOptionClicked = onPingOptionClicked, onImagesPicked = onImagesPicked, @@ -249,29 +249,36 @@ private fun BaseComposerPreview( ) ) } - val messageTextState = rememberTextFieldState() + + val messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) } + val messageComposition = remember { mutableStateOf(MessageComposition(ConversationId("value", "domain"))) } val keyboardController = LocalSoftwareKeyboardController.current val focusRequester = remember { FocusRequester() } + val messageCompositionHolder = remember { + mutableStateOf( + MessageCompositionHolder( + messageComposition = messageComposition, + messageTextFieldValue = messageTextFieldValue, + onClearDraft = {}, + onSaveDraft = {}, + onSearchMentionQueryChanged = {}, + onClearMentionSearchResult = {}, + onTypingEvent = {} + ) + ) + } MessageComposer( conversationId = ConversationId("value", "domain"), bottomSheetVisible = false, messageComposerStateHolder = MessageComposerStateHolder( messageComposerViewState = messageComposerViewState, messageCompositionInputStateHolder = MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester ), - messageCompositionHolder = MessageCompositionHolder( - messageComposition = messageComposition, - messageTextState = messageTextState, - onClearDraft = {}, - onSaveDraft = {}, - onSearchMentionQueryChanged = {}, - onClearMentionSearchResult = {}, - onTypingEvent = {} - ), + messageCompositionHolder = messageCompositionHolder, additionalOptionStateHolder = AdditionalOptionStateHolder(), ), onPingOptionClicked = { }, @@ -288,6 +295,12 @@ private fun BaseComposerPreview( ) } +@PreviewMultipleThemes +@Composable +private fun PreviewMessageComposerEnabled() = WireTheme { + BaseComposerPreview(interactionAvailability = InteractionAvailability.ENABLED) +} + @PreviewMultipleThemes @Composable private fun PreviewMessageComposerDeletedUser() = WireTheme { @@ -311,9 +324,3 @@ private fun PreviewMessageComposerUnsupportedProtocol() = WireTheme { private fun PreviewMessageComposerLegalHold() = WireTheme { BaseComposerPreview(interactionAvailability = InteractionAvailability.LEGAL_HOLD) } - -@PreviewMultipleThemes -@Composable -private fun PreviewMessageComposerEnabled() = WireTheme { - BaseComposerPreview(interactionAvailability = InteractionAvailability.ENABLED) -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt index 48e30853d6f..e9c2b9012bf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt @@ -29,16 +29,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -53,6 +52,7 @@ import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension @@ -68,6 +68,7 @@ import com.wire.android.ui.common.textfield.WireTextFieldColors import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.home.conversations.UsersTypingIndicatorForConversation import com.wire.android.ui.home.conversations.messages.QuotedMessagePreview +import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionArgs import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionViewModel import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionViewModelImpl @@ -85,7 +86,9 @@ import com.wire.kalium.logic.data.message.SelfDeletionTimer fun ActiveMessageComposerInput( conversationId: ConversationId, messageComposition: MessageComposition, - messageTextState: TextFieldState, + messageTextFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, + mentions: List, isTextExpanded: Boolean, inputType: InputType, focusRequester: FocusRequester, @@ -128,7 +131,9 @@ fun ActiveMessageComposerInput( InputContent( conversationId = conversationId, - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, + onValueChange = onValueChange, + mentions = mentions, isTextExpanded = isTextExpanded, inputType = inputType, focusRequester = focusRequester, @@ -165,7 +170,9 @@ fun ActiveMessageComposerInput( @Composable private fun InputContent( conversationId: ConversationId, - messageTextState: TextFieldState, + messageTextFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, + mentions: List, isTextExpanded: Boolean, inputType: InputType, focusRequester: FocusRequester, @@ -208,7 +215,9 @@ private fun InputContent( isTextExpanded = isTextExpanded, focusRequester = focusRequester, colors = inputType.inputTextColor(isSelfDeleting = viewModel.state().duration != null), - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, + onValueChange = onValueChange, + mentions = mentions, placeHolderText = viewModel.state().duration?.let { stringResource(id = R.string.self_deleting_message_label) } ?: inputType.labelText(), onFocused = onFocused, @@ -265,10 +274,12 @@ private fun InputContent( @OptIn(ExperimentalComposeUiApi::class) @Composable private fun MessageComposerTextInput( + mentions: List, isTextExpanded: Boolean, focusRequester: FocusRequester, colors: WireTextFieldColors, - messageTextState: TextFieldState, + messageTextFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, placeHolderText: String, onTextCollapse: () -> Unit, onFocused: () -> Unit, @@ -286,9 +297,10 @@ private fun MessageComposerTextInput( } WireTextField( - textState = messageTextState, + textFieldValue = messageTextFieldValue, + onValueChange = onValueChange, + mentions = mentions, colors = colors, - lineLimits = TextFieldLineLimits.MultiLine(), textStyle = MaterialTheme.wireTypography.body01, // Add an extra space so that the cursor is placed one space before "Type a message" placeholderText = " $placeHolderText", @@ -354,7 +366,9 @@ private fun PreviewActiveMessageComposerInput(inputType: InputType, isTextExpand ActiveMessageComposerInput( conversationId = ConversationId("conversationId", "domain"), messageComposition = MessageComposition(ConversationId("conversationId", "domain")), - messageTextState = rememberTextFieldState("abc"), + mentions = emptyList(), + messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) }, + onValueChange = {}, isTextExpanded = isTextExpanded, inputType = inputType, focusRequester = FocusRequester(), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt index 771e8e77138..0ea6534dc7d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt @@ -18,8 +18,6 @@ package com.wire.android.ui.home.messagecomposer.state -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State @@ -29,6 +27,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.ui.home.conversations.MessageComposerViewState import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.messagecomposer.model.MessageComposition @@ -52,26 +52,33 @@ fun rememberMessageComposerStateHolder( val messageComposition = remember(draftMessageComposition) { mutableStateOf(draftMessageComposition) } - val messageTextState = rememberTextFieldState() + + val messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) } + LaunchedEffect(draftMessageComposition.draftText) { if (draftMessageComposition.draftText.isNotBlank()) { - messageTextState.setTextAndPlaceCursorAtEnd(draftMessageComposition.draftText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = draftMessageComposition.draftText, + selection = TextRange(draftMessageComposition.draftText.length) // Place cursor at the end of the new text + ) } } val messageCompositionHolder = remember { - MessageCompositionHolder( - messageComposition = messageComposition, - messageTextState = messageTextState, - onClearDraft = onClearDraft, - onSaveDraft = onSaveDraft, - onSearchMentionQueryChanged = onSearchMentionQueryChanged, - onClearMentionSearchResult = onClearMentionSearchResult, - onTypingEvent = onTypingEvent, + mutableStateOf( + MessageCompositionHolder( + messageComposition = messageComposition, + messageTextFieldValue = messageTextFieldValue, + onClearDraft = onClearDraft, + onSaveDraft = onSaveDraft, + onSearchMentionQueryChanged = onSearchMentionQueryChanged, + onClearMentionSearchResult = onClearMentionSearchResult, + onTypingEvent = onTypingEvent, + ) ) } LaunchedEffect(Unit) { - messageCompositionHolder.handleMessageTextUpdates() + messageCompositionHolder.value.handleMessageTextUpdates() } val keyboardController = LocalSoftwareKeyboardController.current val focusRequester = remember { @@ -80,14 +87,14 @@ fun rememberMessageComposerStateHolder( val messageCompositionInputStateHolder = rememberSaveable( saver = MessageCompositionInputStateHolder.saver( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester, density = density ) ) { MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester ) @@ -116,18 +123,18 @@ fun rememberMessageComposerStateHolder( class MessageComposerStateHolder( val messageComposerViewState: State, val messageCompositionInputStateHolder: MessageCompositionInputStateHolder, - val messageCompositionHolder: MessageCompositionHolder, + val messageCompositionHolder: State, val additionalOptionStateHolder: AdditionalOptionStateHolder, ) { - val messageComposition = messageCompositionHolder.messageComposition + val messageComposition = messageCompositionHolder.value.messageComposition fun toEdit(messageId: String, editMessageText: String, mentions: List) { - messageCompositionHolder.setEditText(messageId, editMessageText, mentions) + messageCompositionHolder.value.setEditText(messageId, editMessageText, mentions) messageCompositionInputStateHolder.toEdit(editMessageText) } fun toReply(message: UIMessage.Regular) { - messageCompositionHolder.setReply(message) + messageCompositionHolder.value.setReply(message) messageCompositionInputStateHolder.toComposing() } @@ -146,7 +153,7 @@ class MessageComposerStateHolder( fun cancelEdit() { messageCompositionInputStateHolder.toComposing() - messageCompositionHolder.clearMessage() + messageCompositionHolder.value.clearMessage() } fun showAttachments(showOptions: Boolean) { @@ -154,6 +161,6 @@ class MessageComposerStateHolder( } fun clearMessage() { - messageCompositionHolder.clearMessage() + messageCompositionHolder.value.clearMessage() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt index 3d70862f77f..18059202bb4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt @@ -17,12 +17,10 @@ */ package com.wire.android.ui.home.messagecomposer.state -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.MutableState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.substring import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.home.conversations.model.UIMessage @@ -53,7 +51,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged @Suppress("TooManyFunctions") class MessageCompositionHolder( val messageComposition: MutableState, - val messageTextState: TextFieldState, + var messageTextFieldValue: MutableState, val onClearDraft: () -> Unit, private val onSaveDraft: (MessageDraft) -> Unit, private val onSearchMentionQueryChanged: (String) -> Unit, @@ -72,7 +70,7 @@ class MessageCompositionHolder( editMessageId = null ) } - onSaveDraft(messageComposition.value.toDraft(messageTextState.text.toString())) + onSaveDraft(messageComposition.value.toDraft(messageTextFieldValue.value.text)) } fun setReply(message: UIMessage.Regular) { @@ -96,7 +94,7 @@ class MessageCompositionHolder( ) } } - onSaveDraft(messageComposition.value.toDraft(messageTextState.text.toString())) + onSaveDraft(messageComposition.value.toDraft(messageTextFieldValue.value.text)) } fun clearReply() { @@ -110,13 +108,13 @@ class MessageCompositionHolder( } suspend fun handleMessageTextUpdates() { - snapshotFlow { messageTextState.text to messageTextState.selection } + snapshotFlow { messageTextFieldValue.value.text to messageTextFieldValue.value.selection } .distinctUntilChanged() .collectLatest { (messageText, selection) -> - updateTypingEvent(messageText.toString()) - updateMentionsIfNeeded(messageText.toString()) - requestMentionSuggestionIfNeeded(messageText.toString(), selection) - onSaveDraft(messageComposition.value.toDraft(messageText.toString())) + updateTypingEvent(messageText) + updateMentionsIfNeeded(messageText) + requestMentionSuggestionIfNeeded(messageText, selection) + onSaveDraft(messageComposition.value.toDraft(messageText)) } } @@ -162,8 +160,8 @@ class MessageCompositionHolder( } fun startMention() { - val beforeSelection = messageTextState.text - .subSequence(0, messageTextState.selection.min) + val beforeSelection = messageTextFieldValue.value.text + .subSequence(0, messageTextFieldValue.value.selection.min) .run { if (endsWith(String.WHITE_SPACE) || endsWith(String.NEW_LINE_SYMBOL) || this == String.EMPTY) { this.toString() @@ -174,10 +172,10 @@ class MessageCompositionHolder( } } - val afterSelection = messageTextState.text + val afterSelection = messageTextFieldValue.value.text .subSequence( - messageTextState.selection.max, - messageTextState.text.length + messageTextFieldValue.value.selection.max, + messageTextFieldValue.value.text.length ) val resultText = StringBuilder(beforeSelection) @@ -186,16 +184,16 @@ class MessageCompositionHolder( .toString() val newSelection = TextRange(beforeSelection.length + 1) - messageTextState.edit { - replace(0, messageTextState.text.length, resultText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text.replaceRange(0, messageTextFieldValue.value.text.length, resultText), selection = newSelection - } + ) requestMentionSuggestionIfNeeded(resultText, newSelection) } fun addMention(contact: Contact) { val mention = UIMention( - start = currentMentionStartIndex(messageTextState.text.toString(), messageTextState.selection), + start = currentMentionStartIndex(messageTextFieldValue.value.text, messageTextFieldValue.value.selection), length = contact.name.length + 1, // +1 cause there is an "@" before it userId = UserId(contact.id, contact.domain), handler = String.MENTION_SYMBOL + contact.name @@ -209,12 +207,12 @@ class MessageCompositionHolder( } private fun insertMentionIntoText(mention: UIMention) { - val beforeMentionText = messageTextState.text + val beforeMentionText = messageTextFieldValue.value.text .subSequence(0, mention.start) - val afterMentionText = messageTextState.text + val afterMentionText = messageTextFieldValue.value.text .subSequence( - messageTextState.selection.max, - messageTextState.text.length + messageTextFieldValue.value.selection.max, + messageTextFieldValue.value.text.length ) val resultText = StringBuilder() .append(beforeMentionText) @@ -227,16 +225,18 @@ class MessageCompositionHolder( // + 1 cause we add space after mention and move selector there val newSelection = TextRange(beforeMentionText.length + mention.handler.length + 1) - - messageTextState.edit { - replace(0, messageTextState.text.length, resultText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text.replaceRange(0, messageTextFieldValue.value.text.length, resultText), selection = newSelection - } + ) onSaveDraft(messageComposition.value.toDraft(resultText)) } fun setEditText(messageId: String, editMessageText: String, mentions: List) { - messageTextState.setTextAndPlaceCursorAtEnd(editMessageText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = editMessageText, + selection = TextRange(editMessageText.length) // Place cursor at the end of the new text + ) messageComposition.update { it.copy( selectedMentions = mentions.mapNotNull { it.toUiMention(editMessageText) }, @@ -250,9 +250,9 @@ class MessageCompositionHolder( markdown: RichTextMarkdown, ) { val isHeader = markdown == RichTextMarkdown.Header - val range = messageTextState.selection - val selectedText = messageTextState.text.substring(messageTextState.selection) - val stringBuilder = StringBuilder(messageTextState.text.toString()) + val range = messageTextFieldValue.value.selection + val selectedText = messageTextFieldValue.value.text.substring(messageTextFieldValue.value.selection) + val stringBuilder = StringBuilder(messageTextFieldValue.value.text) val markdownLength = markdown.value.length val markdownLengthComplete = if (isHeader) markdownLength else (markdownLength * RICH_TEXT_MARKDOWN_MULTIPLIER) @@ -285,15 +285,15 @@ class MessageCompositionHolder( } val newMessageText = stringBuilder.toString() - messageTextState.edit { - replace(0, messageTextState.text.length, newMessageText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text.replaceRange(0, messageTextFieldValue.value.text.length, newMessageText), selection = TextRange(selectionStart, selectionEnd) - } + ) onSaveDraft(messageComposition.value.toDraft(newMessageText)) } fun clearMessage() { - messageTextState.clearText() + messageTextFieldValue.value = TextFieldValue(String.EMPTY) messageComposition.update { it.copy( quotedMessageId = null, @@ -305,7 +305,7 @@ class MessageCompositionHolder( } fun toMessageBundle(conversationId: ConversationId) = - messageComposition.value.toMessageBundle(conversationId, messageTextState.text.toString()) + messageComposition.value.toMessageBundle(conversationId, messageTextFieldValue.value.text) private fun currentMentionStartIndex(messageText: String, selection: TextRange): Int { val lastIndexOfAt = messageText.lastIndexOf(String.MENTION_SYMBOL, selection.min - 1) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt index 8725f4bbf74..320655a133d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt @@ -18,8 +18,8 @@ package com.wire.android.ui.home.messagecomposer.state import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -30,6 +30,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -42,7 +43,7 @@ import com.wire.android.util.isNotMarkdownBlank @Stable class MessageCompositionInputStateHolder( - val messageTextState: TextFieldState, + val messageTextFieldValue: MutableState, private val keyboardController: SoftwareKeyboardController?, val focusRequester: FocusRequester ) { @@ -64,11 +65,12 @@ class MessageCompositionInputStateHolder( val inputType: InputType by derivedStateOf { when (val state = compositionState) { is CompositionState.Composing -> InputType.Composing( - isSendButtonEnabled = messageTextState.text.isNotMarkdownBlank() + isSendButtonEnabled = messageTextFieldValue.value.text.isNotMarkdownBlank() ) is CompositionState.Editing -> InputType.Editing( - isEditButtonEnabled = messageTextState.text != state.originalMessageText && messageTextState.text.isNotMarkdownBlank() + isEditButtonEnabled = messageTextFieldValue.value.text != state.originalMessageText && + messageTextFieldValue.value.text.isNotMarkdownBlank() ) } } @@ -170,7 +172,7 @@ class MessageCompositionInputStateHolder( val composeTextHeight = 128.dp fun saver( - messageTextState: TextFieldState, + messageTextFieldValue: MutableState, keyboardController: SoftwareKeyboardController?, focusRequester: FocusRequester, density: Density @@ -188,7 +190,7 @@ class MessageCompositionInputStateHolder( restore = { savedState -> with(density) { MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester ).apply { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt index 880408327af..b4e763d20c8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt @@ -203,7 +203,7 @@ private fun SelfQRCodeContent( color = colorsScheme().secondaryText ) Spacer(modifier = Modifier.weight(1f)) - ShareLinkButton(state.userProfileLink, trackAnalyticsEvent) + ShareLinkButton(state.userAccountProfileLink, trackAnalyticsEvent) VerticalSpace.x8() ShareQRCodeButton { trackAnalyticsEvent(AnalyticsEvent.QrCode.Modal.ShareQrCode) diff --git a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt index f5dbab4b6e5..17cac8553cf 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt @@ -145,7 +145,7 @@ object TestMessage { val UI_MESSAGE_HEADER = MessageHeader( username = UIText.DynamicString("username"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = MessageTime(Instant.parse("2022-03-30T15:36:00.000Z")), messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt index 79e3abce3cb..92937700fdb 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt @@ -23,9 +23,11 @@ import com.wire.android.config.NavigationTestExtension import com.wire.android.datastore.GlobalDataStore import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.OngoingCallViewModel +import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient +import com.wire.kalium.logic.data.call.CallQuality import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.conversation.Conversation @@ -170,6 +172,72 @@ class OngoingCallViewModelTest { } } + @Test + fun givenAUserIsSelected_whenRequestedFullScreen_thenSetTheUserAsSelected() = + runTest { + val (_, ongoingCallViewModel) = Arrangement() + .withCall(provideCall().copy(isCameraOn = true)) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .arrange() + + ongoingCallViewModel.onSelectedParticipant(selectedParticipant3) + + assertEquals(selectedParticipant3, ongoingCallViewModel.selectedParticipant) + } + + @Test + fun givenParticipantsList_WhenRequestingVideoStreamForFullScreenParticipant_ThenRequestItInHighQuality() = + runTest { + val expectedClients = listOf( + CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.HIGH) + ) + + val (arrangement, ongoingCallViewModel) = Arrangement() + .withCall(provideCall()) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .withRequestVideoStreams(conversationId, expectedClients) + .arrange() + + ongoingCallViewModel.onSelectedParticipant(selectedParticipant3) + ongoingCallViewModel.requestVideoStreams(participants) + + coVerify(exactly = 1) { + arrangement.requestVideoStreams( + conversationId, + expectedClients + ) + } + } + + @Test + fun givenParticipantsList_WhenRequestingVideoStreamForAllParticipant_ThenRequestItInLowQuality() = + runTest { + val expectedClients = listOf( + CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.LOW) + ) + + val (arrangement, ongoingCallViewModel) = Arrangement() + .withCall(provideCall()) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .withRequestVideoStreams(conversationId, expectedClients) + .arrange() + + ongoingCallViewModel.onSelectedParticipant(SelectedParticipant()) + ongoingCallViewModel.requestVideoStreams(participants) + + coVerify(exactly = 1) { + arrangement.requestVideoStreams( + conversationId, + expectedClients + ) + } + } + private class Arrangement { @MockK @@ -268,6 +336,7 @@ class OngoingCallViewModelTest { accentId = -1 ) val participants = listOf(participant1, participant2, participant3) + val selectedParticipant3 = SelectedParticipant(participant3.id, participant3.clientId, false) } private fun provideCall( diff --git a/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt new file mode 100644 index 00000000000..f28ad9b2b00 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt @@ -0,0 +1,97 @@ +/* + * Wire + * Copyright (C) 2024 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.common.textfield + +import androidx.compose.ui.text.TextRange +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class MentionDeletionHandlerTest { + + @Test + fun `given mention in text when deleting inside mention then mention is removed`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello , how are you?" + val oldSelection = TextRange(6, 17) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals("Hello , how are you?", result) + } + + @Test + fun `given mention with last character deleted when deleting last character then mention is removed`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello @John Do, how are you?" + val oldSelection = TextRange(3, 13) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals("Hello , how are you?", result) + } + + @Test + fun `given cursor at beginning of mention when no deletion then text remains unchanged`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello @John Doe, how are you?" + val oldSelection = TextRange(6, 6) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(oldText, result) + } + + @Test + fun `given text with mention when deleting outside of mention then text remains unchanged`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello @John Doehow are you?" + val oldSelection = TextRange(5, 6) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(newText, result) + } + + @Test + fun `given multiple mentions in text when deleting inside mentions then all mentions are removed`() { + val oldText = "Hello @John Doe and @Jane Doe, how are you?" + val newText = "Hello , how are you?" + val oldSelection = TextRange(6, 17) + val mentions = listOf("@John Doe", "@Jane Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(newText, result) + } + + @Test + fun `given text without mentions when no mentions to delete then text remains unchanged`() { + val oldText = "Hello there, how are you?" + val newText = "Hello, how are you?" + val oldSelection = TextRange(6, 6) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(newText, result) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt index a0413084b0e..4387a91ec30 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt @@ -225,7 +225,7 @@ internal fun mockUITextMessage(id: String = "someId", userName: String = "mockUs every { it.header } returns mockk().also { every { it.messageId } returns id every { it.username } returns UIText.DynamicString(userName) - every { it.isLegalHold } returns false + every { it.showLegalHoldIndicator } returns false every { it.messageTime } returns MessageTime(Instant.DISTANT_PAST) every { it.messageStatus } returns MessageStatus( flowStatus = MessageFlowStatus.Sent, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index d9e1cdb253f..5f6ea2533dd 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -19,7 +19,11 @@ package com.wire.android.ui.home.conversationslist +import androidx.paging.LoadState +import androidx.paging.LoadStates import androidx.paging.PagingData +import androidx.paging.testing.asSnapshot +import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri @@ -29,6 +33,7 @@ import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper import com.wire.android.ui.common.dialogs.BlockUserDialogState import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase +import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents @@ -47,6 +52,8 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetails import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMetadataUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase +import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser +import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import com.wire.kalium.logic.feature.team.DeleteTeamConversationUseCase import io.mockk.MockKAnnotations @@ -55,7 +62,9 @@ import io.mockk.coVerify import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -66,64 +75,116 @@ class ConversationListViewModelTest { private val dispatcherProvider = TestDispatcherProvider() - // TODO: reenable this test once pagination is implemented -// @Test -// fun `given initial empty search query, when collecting conversations, then call use case with proper params`() = -// runTest(dispatcherProvider.main()) { -// // Given -// val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() -// -// // When -// conversationListViewModel.conversationListState.foldersWithConversations.test { -// // Then -// coVerify(exactly = 1) { -// arrangement.getConversationsPaginated("", false, true, false) -// } -// cancelAndIgnoreRemainingEvents() -// } -// } - - // TODO: reenable this test once pagination is implemented -// @Test -// fun `given updated non-empty search query, when collecting conversations, then call use case with proper params`() = -// runTest(dispatcherProvider.main()) { -// // Given -// val searchQueryText = "search" -// val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() -// -// // When -// conversationListViewModel.conversationListState.foldersWithConversations.test { -// conversationListViewModel.searchQueryChanged(searchQueryText) -// advanceUntilIdle() -// -// // Then -// coVerify(exactly = 1) { -// arrangement.getConversationsPaginated(searchQueryText, false, true, false) -// } -// cancelAndIgnoreRemainingEvents() -// } -// } - - // TODO: reenable this test once pagination is implemented -// @Test -// fun `given updated non-empty search query, when collecting archived, then call use case with proper params`() = -// runTest(dispatcherProvider.main()) { -// // Given -// val searchQueryText = "search" -// val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.ARCHIVE).arrange() -// -// // When -// conversationListViewModel.conversationListState.foldersWithConversations.test { -// conversationListViewModel.searchQueryChanged(searchQueryText) -// advanceUntilIdle() -// -// // Then -// coVerify(exactly = 1) { -// arrangement.getConversationsPaginated(searchQueryText, true, false, false) -// } -// cancelAndIgnoreRemainingEvents() -// } -// } + @Test + fun `given initial empty search query, when collecting conversations, then call use case with proper params`() = + runTest(dispatcherProvider.main()) { + // Given + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated("", false, true, false) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given updated non-empty search query, when collecting conversations, then call use case with proper params`() = + runTest(dispatcherProvider.main()) { + // Given + val searchQueryText = "search" + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + conversationListViewModel.searchQueryChanged(searchQueryText) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(searchQueryText, false, true, false) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given updated non-empty search query, when collecting archived, then call use case with proper params`() = + runTest(dispatcherProvider.main()) { + // Given + val searchQueryText = "search" + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.ARCHIVE).arrange() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + conversationListViewModel.searchQueryChanged(searchQueryText) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(searchQueryText, true, false, false) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given self user is under legal hold, when collecting conversations, then hide LH indicators`() = + runTest(dispatcherProvider.main()) { + // Given + val conversations = listOf( + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_1", ""), showLegalHoldIndicator = true), + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_2", ""), showLegalHoldIndicator = false), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_1", ""), showLegalHoldIndicator = true), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_2", ""), showLegalHoldIndicator = false), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_1", ""), showLegalHoldIndicator = true), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_2", ""), showLegalHoldIndicator = false), + ).associateBy { it.conversationId } + val (_, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN) + .withConversationsPaginated(conversations.values.toList()) + .withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Enabled) + .arrange() + advanceUntilIdle() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.asSnapshot() + .filterIsInstance() + .forEach { + // Then + assertEquals(false, it.showLegalHoldIndicator) // self user is under LH so hide LH indicators next to conversations + } + } + + @Test + fun `given self user is not under legal hold, when collecting conversations, then show LH indicator when conversation is under LH`() = + runTest(dispatcherProvider.main()) { + // Given + val conversations = listOf( + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_1", ""), showLegalHoldIndicator = true), + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_2", ""), showLegalHoldIndicator = false), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_1", ""), showLegalHoldIndicator = true), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_2", ""), showLegalHoldIndicator = false), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_1", ""), showLegalHoldIndicator = true), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_2", ""), showLegalHoldIndicator = false), + ).associateBy { it.conversationId } + val (_, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN) + .withConversationsPaginated(conversations.values.toList()) + .withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Disabled) + .arrange() + advanceUntilIdle() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.asSnapshot() + .filterIsInstance() + .forEach { + // Then + val expected = conversations[it.conversationId]!!.showLegalHoldIndicator // show indicator when conversation is under LH + assertEquals(expected, it.showLegalHoldIndicator) + } + } @Test fun `given a valid conversation muting state, when calling muteConversation, then should call with call the UseCase`() = @@ -207,16 +268,16 @@ class ConversationListViewModelTest { private lateinit var observeConversationListDetailsWithEventsUseCase: ObserveConversationListDetailsWithEventsUseCase + @MockK + private lateinit var observeLegalHoldStateForSelfUserUseCase: ObserveLegalHoldStateForSelfUserUseCase + @MockK private lateinit var wireSessionImageLoader: WireSessionImageLoader init { MockKAnnotations.init(this, relaxUnitFun = true) - coEvery { - getConversationsPaginated.invoke(any(), any(), any(), any()) - } returns flowOf( - PagingData.from(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) - ) + withConversationsPaginated(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) + withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Disabled) coEvery { observeConversationListDetailsWithEventsUseCase.invoke(false, ConversationFilter.ALL) } returns flowOf( listOf( TestConversationDetails.CONNECTION, @@ -245,6 +306,25 @@ class ConversationListViewModelTest { coEvery { unblockUser(any()) } returns UnblockUserResult.Success } + fun withConversationsPaginated(items: List) = apply { + coEvery { + getConversationsPaginated.invoke(any(), any(), any(), any()) + } returns flowOf( + PagingData.from( + data = items, + sourceLoadStates = LoadStates( + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true), + refresh = LoadState.NotLoading(true), + ), + ) + ) + } + + fun withSelfUserLegalHoldState(LegalHoldStateForSelfUser: LegalHoldStateForSelfUser) = apply { + coEvery { observeLegalHoldStateForSelfUserUseCase() } returns flowOf(LegalHoldStateForSelfUser) + } + fun arrange() = this to ConversationListViewModelImpl( conversationsSource = conversationsSource, dispatcher = dispatcherProvider, @@ -260,8 +340,10 @@ class ConversationListViewModelTest { updateConversationArchivedStatus = updateConversationArchivedStatus, currentAccount = TestUser.SELF_USER_ID, observeConversationListDetailsWithEvents = observeConversationListDetailsWithEventsUseCase, + observeLegalHoldStateForSelfUser = observeLegalHoldStateForSelfUserUseCase, userTypeMapper = UserTypeMapper(), - wireSessionImageLoader = wireSessionImageLoader + wireSessionImageLoader = wireSessionImageLoader, + usePagination = true, ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt index 03fe454d77a..8a55dfb5bd5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt @@ -19,11 +19,12 @@ package com.wire.android.ui.home.messagecomposer import android.content.Context -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestConversation @@ -61,10 +62,10 @@ class MessageComposerStateHolderTest { private lateinit var messageComposerViewState: MutableState private lateinit var messageComposition: MutableState private lateinit var messageCompositionInputStateHolder: MessageCompositionInputStateHolder - private lateinit var messageCompositionHolder: MessageCompositionHolder + private lateinit var messageCompositionHolder: State private lateinit var additionalOptionStateHolder: AdditionalOptionStateHolder private lateinit var state: MessageComposerStateHolder - private lateinit var messageTextState: TextFieldState + private lateinit var messageTextFieldValue: MutableState @BeforeEach fun before() { @@ -73,20 +74,22 @@ class MessageComposerStateHolderTest { every { focusRequester.captureFocus() } returns true messageComposerViewState = mutableStateOf(MessageComposerViewState()) messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) - messageTextState = TextFieldState() + messageTextFieldValue = mutableStateOf(TextFieldValue()) messageCompositionInputStateHolder = MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = null, focusRequester = focusRequester ) - messageCompositionHolder = MessageCompositionHolder( - messageComposition = messageComposition, - messageTextState = messageTextState, - onClearDraft = {}, - onSaveDraft = {}, - onSearchMentionQueryChanged = {}, - onClearMentionSearchResult = {}, - onTypingEvent = {}, + messageCompositionHolder = mutableStateOf( + MessageCompositionHolder( + messageComposition = messageComposition, + messageTextFieldValue = messageTextFieldValue, + onClearDraft = {}, + onSaveDraft = {}, + onSearchMentionQueryChanged = {}, + onClearMentionSearchResult = {}, + onTypingEvent = {}, + ) ) additionalOptionStateHolder = AdditionalOptionStateHolder() @@ -131,9 +134,13 @@ class MessageComposerStateHolderTest { editMessageText = "edit_message_text", mentions = listOf() ) - state.messageCompositionHolder.messageTextState.edit { - append("some text") - } + + state.messageCompositionHolder.value.messageTextFieldValue.value = + messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text + "some text", + selection = TextRange(messageTextFieldValue.value.text.length + "some text".length) + ) + assertInstanceOf(InputType.Editing::class.java, messageCompositionInputStateHolder.inputType).also { assertEquals(true, it.isEditButtonEnabled) } @@ -147,7 +154,7 @@ class MessageComposerStateHolderTest { state.toReply(mockMessageWithText) // then - assertEquals(String.EMPTY, messageCompositionHolder.messageTextState.text.toString()) + assertEquals(String.EMPTY, messageCompositionHolder.value.messageTextFieldValue.value.text) assertInstanceOf(InputType.Composing::class.java, messageCompositionInputStateHolder.inputType) } @@ -155,13 +162,15 @@ class MessageComposerStateHolderTest { fun `given some message was being composed, when setting toReply, then input continues with the current text`() = runTest { // given val currentText = "Potato" - messageCompositionHolder.messageTextState.setTextAndPlaceCursorAtEnd(currentText) - + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = currentText, + selection = TextRange(currentText.length) + ) // when state.toReply(mockMessageWithText) // then - assertEquals(currentText, messageCompositionHolder.messageTextState.text.toString()) + assertEquals(currentText, messageCompositionHolder.value.messageTextFieldValue.value.text) } @Test @@ -188,15 +197,15 @@ class MessageComposerStateHolderTest { // then assertEquals( String.EMPTY, - messageCompositionHolder.messageTextState.text.toString() + messageCompositionHolder.value.messageTextFieldValue.value.text ) assertEquals( null, - messageCompositionHolder.messageComposition.value.quotedMessage + messageCompositionHolder.value.messageComposition.value.quotedMessage ) assertEquals( null, - messageCompositionHolder.messageComposition.value.quotedMessageId + messageCompositionHolder.value.messageComposition.value.quotedMessageId ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt index 91fee977b50..e0cab8fb694 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt @@ -18,10 +18,10 @@ package com.wire.android.ui.home.messagecomposer.state import android.content.Context -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestConversation import com.wire.android.ui.home.messagecomposer.model.MessageComposition @@ -47,7 +47,7 @@ class MessageCompositionHolderTest { private lateinit var state: MessageCompositionHolder private lateinit var messageComposition: MutableState - private lateinit var messageTextState: TextFieldState + private lateinit var messageTextFieldValue: MutableState private val dispatcher = StandardTestDispatcher() @BeforeEach @@ -56,10 +56,10 @@ class MessageCompositionHolderTest { Dispatchers.setMain(dispatcher) messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) - messageTextState = TextFieldState() + messageTextFieldValue = mutableStateOf(TextFieldValue()) state = MessageCompositionHolder( messageComposition = messageComposition, - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, onClearDraft = {}, onSaveDraft = {}, onSearchMentionQueryChanged = {}, @@ -82,7 +82,7 @@ class MessageCompositionHolderTest { // then assertEquals( "# ", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @@ -95,7 +95,7 @@ class MessageCompositionHolderTest { // then assertEquals( "****", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @@ -108,62 +108,52 @@ class MessageCompositionHolderTest { // then assertEquals( "__", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @Test fun `given non empty text, when adding header markdown on selection, then # is added to the text`() = runTest { // given - state.messageTextState.edit { - replace(0, length, "header") - selection = TextRange( - start = 0, - end = 6 - ) - } - + val newText = "header" + state.messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = newText, + selection = TextRange(0, 6) + ) // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Header) // then assertEquals( "# header", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @Test fun `given non empty text, when adding bold markdown on selection, then 2x star char is added to the text`() = runTest { // given - state.messageTextState.edit { - replace(0, length, "bold") - selection = TextRange( - start = 0, - end = 4 - ) - } - + state.messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = "bold", // Replace the entire text with "bold" + selection = TextRange(0, 4) + ) // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Bold) // then assertEquals( "**bold**", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @Test fun `given non empty text, when adding italic markdown on selection, then 2x _ is added to the text`() = runTest { // given - state.messageTextState.edit { - replace(0, length, "italic") - selection = TextRange( - start = 0, - end = 6 - ) - } + state.messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = "italic", // Replace the entire text with "bold" + selection = TextRange(0, 6) + ) // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Italic) @@ -171,7 +161,7 @@ class MessageCompositionHolderTest { // then assertEquals( "_italic_", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt index 9364fc0fd32..81f3a08a537 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt @@ -20,10 +20,11 @@ package com.wire.android.ui.home.messagecomposer.state -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension @@ -233,14 +234,14 @@ class MessageCompositionInputStateHolderTest { class Arrangement { - private val textFieldState = TextFieldState() + private val textFieldValue = mutableStateOf(TextFieldValue()) val softwareKeyboardController = mockk() private val focusRequester = mockk() private val state by lazy { - MessageCompositionInputStateHolder(textFieldState, softwareKeyboardController, focusRequester) + MessageCompositionInputStateHolder(textFieldValue, softwareKeyboardController, focusRequester) } init { @@ -251,7 +252,10 @@ class MessageCompositionInputStateHolderTest { } fun withText(text: String) = apply { - textFieldState.setTextAndPlaceCursorAtEnd(text) + textFieldValue.value = textFieldValue.value.copy( + text = text, + selection = TextRange(text.length) + ) } fun arrange() = state to this diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt index 78ef8edb4e4..bee5ab38855 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt @@ -25,6 +25,7 @@ import com.wire.android.feature.analytics.model.AnalyticsEventConstants import com.wire.android.feature.analytics.model.AnalyticsSettings import ly.count.android.sdk.Countly import ly.count.android.sdk.CountlyConfig +import ly.count.android.sdk.UtilsInternalLimits class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { @@ -40,12 +41,18 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { context, analyticsSettings.countlyAppKey, analyticsSettings.countlyServerUrl - ) - .enableTemporaryDeviceIdMode() // Nothing is sent until a proper ID is placed - .setLoggingEnabled(analyticsSettings.enableDebugLogging) - countlyConfig.apm.enableAppStartTimeTracking() - countlyConfig.apm.enableForegroundBackgroundTracking() - countlyConfig.setApplication(context.applicationContext as Application) + ).apply { + setApplication(context.applicationContext as Application) + enableTemporaryDeviceIdMode() // Nothing is sent until a proper ID is placed + setLoggingEnabled(analyticsSettings.enableDebugLogging) + crashes.apply { + enableCrashReporting() + } + apm.apply { + enableAppStartTimeTracking() + enableForegroundBackgroundTracking() + } + } Countly.sharedInstance().init(countlyConfig) Countly.sharedInstance().consent().giveConsent(arrayOf("apm")) @@ -67,8 +74,13 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { Countly.sharedInstance().onStop() } + /** + * We need to change our segmentation map to [MutableMap] because + * Countly is doing additional operations on it. + * See [UtilsInternalLimits.removeUnsupportedDataTypes] + */ override fun sendEvent(event: AnalyticsEvent) { - Countly.sharedInstance().events().recordEvent(event.key, event.toSegmentation()) + Countly.sharedInstance().events().recordEvent(event.key, event.toSegmentation().toMutableMap()) } override fun halt() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f18f8253b2f..6f99643033a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ detekt = "1.23.6" google-gms = "4.4.2" gms-location = "21.3.0" android-gradlePlugin = "8.5.2" -desugaring = "2.1.2" +desugaring = "2.1.3" firebaseBOM = "33.4.0" fragment = "1.5.6" resaca = "3.0.0"