diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index f4071490078..3daeb5aec17 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter @@ -57,6 +58,7 @@ class PreferencesRootPresenter( private val featureFlagService: FeatureFlagService, private val sessionStore: SessionStore, private val sessionEnterpriseService: SessionEnterpriseService, + private val userStatusPresenter: Presenter, ) : Presenter { @Composable override fun present(): PreferencesRootState { @@ -66,13 +68,14 @@ class PreferencesRootPresenter( // Force a refresh of the profile matrixClient.getUserProfile() } - val isMultiAccountEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount) }.collectAsState(initial = false) val showLinkNewDevice by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) }.collectAsState(initial = false) + val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false) + val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null val otherSessions by remember { sessionStore.sessionsFlow().map { list -> @@ -137,6 +140,7 @@ class PreferencesRootPresenter( return PreferencesRootState( myUser = matrixUser.value, + userStatusState = userStatusState, version = remember { versionFormatter.get() }, deviceId = matrixClient.deviceId, isMultiAccountEnabled = isMultiAccountEnabled, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index 6474f07a699..c415547a802 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.root import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser @@ -16,6 +17,7 @@ import kotlinx.collections.immutable.ImmutableList data class PreferencesRootState( val myUser: MatrixUser, + val userStatusState: UserStatusState?, val version: String, val deviceId: DeviceId?, val isMultiAccountEnabled: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index d53cd008e45..0abed1f66fa 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -11,6 +11,9 @@ package io.element.android.features.preferences.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState +import io.element.android.features.preferences.impl.userstatus.UserStatusState +import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser @@ -64,8 +67,9 @@ open class PreferencesRootStateProvider : PreviewParameterProvider Unit = {}, ) = PreferencesRootState( @@ -105,6 +110,7 @@ fun aPreferencesRootState( nbOfBlockedUsers = nbOfBlockedUsers, showLabsItem = showLabsItem, directLogoutState = directLogoutState, + userStatusState = userStatusState, snackbarMessage = snackbarMessage, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 4afc2d27bfb..8f272ea8ae9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.root import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -23,6 +24,8 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.user.UserPreferences +import io.element.android.features.preferences.impl.userstatus.UserStatusState +import io.element.android.features.preferences.impl.userstatus.UserStatusView import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.list.ListItemContent @@ -85,10 +88,10 @@ fun PreferencesRootView( state = state, onAddAccountClick = onAddAccountClick, ) - } else { - HorizontalDivider() } - // User status will be added here + if (state.userStatusState != null) { + UserStatusSection(state.userStatusState, showTopDivider = !state.isMultiAccountEnabled) + } // 'Account' section ManageAccountSection( state = state, @@ -129,6 +132,27 @@ fun PreferencesRootView( } } +@Composable +private fun ColumnScope.UserStatusSection( + userStatusState: UserStatusState, + showTopDivider: Boolean, +) { + if (showTopDivider) { + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + } + UserStatusView( + state = userStatusState, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider( + thickness = 1.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) +} + @Composable private fun ColumnScope.MultiAccountSection( state: PreferencesRootState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusBindingContainer.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusBindingContainer.kt new file mode 100644 index 00000000000..dfa77483f83 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusBindingContainer.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.userstatus + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@BindingContainer +interface UserStatusBindingContainer { + @Binds + fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt new file mode 100644 index 00000000000..815cd12d66d --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.userstatus + +import io.element.android.libraries.matrix.api.user.UserStatus + +sealed interface UserStatusEvent { + data object OpenPicker : UserStatusEvent + data object DismissPicker : UserStatusEvent + data class SetStatus(val status: UserStatus) : UserStatusEvent + data object OpenCustomInput : UserStatusEvent + data object CancelCustomInput : UserStatusEvent + data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent + data object ClearStatus : UserStatusEvent +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt new file mode 100644 index 00000000000..ea09f59706c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.userstatus + +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.launch + +@Inject +class UserStatusPresenter( + private val matrixClient: MatrixClient, +) : Presenter { + @Composable + override fun present(): UserStatusState { + val userProfile by matrixClient.userProfile.collectAsState() + var pickerState by remember { mutableStateOf(UserStatusPickerState.Hidden) } + val customTextFieldState = rememberTextFieldState() + val coroutineScope = rememberCoroutineScope() + + fun handleEvent(event: UserStatusEvent) { + when (event) { + UserStatusEvent.OpenPicker -> pickerState = UserStatusPickerState.ShowingPicker + UserStatusEvent.DismissPicker -> pickerState = UserStatusPickerState.Hidden + UserStatusEvent.OpenCustomInput -> { + val raw = userProfile.rawStatus + if (raw != null) { + customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text) + } else { + customTextFieldState.clearText() + } + pickerState = UserStatusPickerState.CustomInput( + emoji = raw?.emoji ?: "πŸ˜€", + textFieldState = customTextFieldState, + ) + } + is UserStatusEvent.SetStatus -> { + pickerState = UserStatusPickerState.Hidden + coroutineScope.launch { matrixClient.setUserStatus(event.status) } + } + UserStatusEvent.ClearStatus -> { + pickerState = UserStatusPickerState.Hidden + coroutineScope.launch { matrixClient.clearUserStatus() } + } + UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden + is UserStatusEvent.UpdateCustomEmoji -> { + val current = pickerState as? UserStatusPickerState.CustomInput ?: return + pickerState = current.copy(emoji = event.emoji) + } + } + } + + return UserStatusState( + displayedStatus = userProfile.displayedStatus, + rawStatus = userProfile.rawStatus, + pickerState = pickerState, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt new file mode 100644 index 00000000000..fa61089144f --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.userstatus + +import androidx.annotation.StringRes +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Immutable +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus + +data class UserStatusState( + val displayedStatus: DisplayedStatus?, + val rawStatus: UserStatus?, + val pickerState: UserStatusPickerState, + val eventSink: (UserStatusEvent) -> Unit, +) + +@Immutable +sealed interface UserStatusPickerState { + data object Hidden : UserStatusPickerState + data object ShowingPicker : UserStatusPickerState + data class CustomInput( + val emoji: String, + val textFieldState: TextFieldState, + ) : UserStatusPickerState +} + +enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) { + IN_A_MEETING("πŸ’¬", R.string.common_user_status_in_a_meeting), + FOCUS_TIME("πŸ’‘", R.string.common_user_status_focus_time), + ON_THE_ROAD("πŸš™", R.string.common_user_status_on_the_road), + BE_RIGHT_BACK("β˜•", R.string.common_user_status_be_right_back), + AWAY("🌴", R.string.common_user_status_away), +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt new file mode 100644 index 00000000000..087cb85355b --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.userstatus + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus + +internal class UserStatusStateProvider : PreviewParameterProvider { + override val values get() = sequenceOf( + aUserStatusState(), + aUserStatusState(displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away"))), + aUserStatusState(displayedStatus = DisplayedStatus.InCall(callJoinedTs = 0L)), + aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker), + aUserStatusState( + displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")), + rawStatus = UserStatus("🌴", "Away"), + pickerState = UserStatusPickerState.ShowingPicker, + ), + aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "πŸ˜€", textFieldState = TextFieldState())), + aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "πŸš€", textFieldState = TextFieldState("Working on something"))), + ) +} + +fun aUserStatusState( + displayedStatus: DisplayedStatus? = null, + rawStatus: UserStatus? = null, + pickerState: UserStatusPickerState = UserStatusPickerState.Hidden, + eventSink: (UserStatusEvent) -> Unit = {}, +) = UserStatusState( + displayedStatus = displayedStatus, + rawStatus = rawStatus, + pickerState = pickerState, + eventSink = eventSink, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt new file mode 100644 index 00000000000..7169aaec666 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.userstatus + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.hide +import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun UserStatusView( + state: UserStatusState, + modifier: Modifier = Modifier, +) { + when (val pickerState = state.pickerState) { + UserStatusPickerState.Hidden, UserStatusPickerState.ShowingPicker -> { + val displayedStatus = state.displayedStatus + if (displayedStatus != null) { + CurrentStatusRow( + displayedStatus = displayedStatus, + onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, + onClear = { state.eventSink(UserStatusEvent.ClearStatus) }, + modifier = modifier, + ) + } else { + EmptyStatusRow( + onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, + modifier = modifier, + ) + } + if (pickerState == UserStatusPickerState.ShowingPicker) { + UserStatusPickerBottomSheet( + currentRawStatus = state.rawStatus, + onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, + onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, + onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) }, + ) + } + } + is UserStatusPickerState.CustomInput -> { + CustomStatusInputRow( + emoji = pickerState.emoji, + textFieldState = pickerState.textFieldState, + rawStatus = state.rawStatus, + onConfirm = { + state.eventSink( + UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) + ) + }, + onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, + modifier = modifier, + ) + } + } +} + +@Composable +private fun EmptyStatusRow( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.screen_preferences_user_status_placeholder), + modifier = Modifier.padding(vertical = 16.dp), + ) + }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), + modifier = modifier.clickable(onClick = onClick), + ) +} + +@Composable +private fun CurrentStatusRow( + displayedStatus: DisplayedStatus, + onClick: () -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + val (emoji, text) = when (displayedStatus) { + is DisplayedStatus.UserSet -> displayedStatus.status.emoji to displayedStatus.status.text + is DisplayedStatus.InCall -> "🎧" to stringResource(R.string.common_user_status_on_a_call) + } + ListItem( + headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, + leadingContent = ListItemContent.Custom { EmojiText(emoji) }, + trailingContent = ListItemContent.Custom({ + IconButton( + onClick = onClear, + // Remove the extra padding caused by IconButton + modifier = Modifier.offset(x = 12.dp) + ) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear), + ) + } + }), + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun EmojiText(emoji: String) { + // Ensure the emoji doesn't scale with fontSize to match icons size + val fontSize = with(LocalDensity.current) { 16.dp.toSp() } + Text( + modifier = Modifier.sizeIn(minWidth = 24.dp, minHeight = 24.dp), + text = emoji, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSize), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UserStatusPickerBottomSheet( + currentRawStatus: UserStatus?, + onDismiss: () -> Unit, + onSelectPredefinedStatus: (UserStatus) -> Unit, + onSelectCustomStatus: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + scrollable = true, + modifier = modifier, + ) { + PredefinedUserStatus.entries.forEach { predefined -> + val label = stringResource(predefined.labelRes) + val predefinedUserStatus = UserStatus(emoji = predefined.emoji, text = label) + val isSelected = currentRawStatus == predefinedUserStatus + ListItem( + headlineContent = { Text(text = label) }, + leadingContent = ListItemContent.Custom { EmojiText(predefined.emoji) }, + trailingContent = if (isSelected) { + ListItemContent.Icon(IconSource.Vector(CompoundIcons.Check()), tintColor = ElementTheme.colors.iconAccentPrimary) + } else { + null + }, + onClick = { + sheetState.hide(coroutineScope) { + onSelectPredefinedStatus(predefinedUserStatus) + } + }, + ) + } + ListItem( + headlineContent = { + Text(text = stringResource(R.string.common_user_status_custom)) + }, + leadingContent = ListItemContent.Custom { EmojiText("✏️") }, + onClick = { + sheetState.hide(coroutineScope) { + onSelectCustomStatus() + } + }, + ) + } +} + +private const val CUSTOM_STATUS_TEXT_MAX_LENGTH = 30 + +@Composable +private fun CustomStatusInputRow( + emoji: String, + textFieldState: TextFieldState, + rawStatus: UserStatus?, + onConfirm: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } } + val hasChanges by remember(emoji, rawStatus) { + derivedStateOf { + val text = textFieldState.text.toString() + text.isNotBlank() && UserStatus(emoji, text) != rawStatus + } + } + val saveLabel = stringResource(CommonStrings.action_save) + val cancelLabel = stringResource(CommonStrings.action_cancel) + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + ListItem( + headlineContent = { + TextField( + modifier = Modifier.focusRequester(focusRequester), + state = textFieldState, + placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), + inputTransformation = InputTransformation.maxLength(CUSTOM_STATUS_TEXT_MAX_LENGTH), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + onKeyboardAction = { if (hasChanges) onConfirm() }, + lineLimits = TextFieldLineLimits.SingleLine, + trailingIcon = if (hasText) { + { + Box(modifier = Modifier.clickable { textFieldState.clearText() }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_cancel), + modifier = Modifier.size(22.dp), + ) + } + } + } else { + null + } + ) + }, + trailingContent = ListItemContent.Custom({ + // Layout measures both buttons to determine the max natural width, + // then places only the active one. + Layout( + content = { + TextButton(onClick = onConfirm, text = saveLabel) + TextButton(onClick = onCancel, text = cancelLabel) + } + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + val maxWidth = placeables.maxOf { it.width } + val maxHeight = placeables.maxOf { it.height } + layout(maxWidth, maxHeight) { + val active = if (hasChanges) placeables[0] else placeables[1] + active.placeRelative( + x = (maxWidth - active.width) / 2, + y = (maxHeight - active.height) / 2, + ) + } + } + }), + leadingContent = ListItemContent.Custom({ + Surface( + shape = CircleShape, + border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary), + modifier = Modifier + .size(50.dp) + .clip(CircleShape) + .clickable { }, + ) { + Box(contentAlignment = Alignment.Center) { + EmojiText(emoji) + } + } + }), + modifier = modifier, + ) +} + +@PreviewsDayNight +@Composable +internal fun UserStatusViewPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview { + UserStatusView(state = state) +} diff --git a/features/preferences/impl/src/main/res/values/temporary.xml b/features/preferences/impl/src/main/res/values/temporary.xml new file mode 100644 index 00000000000..b22f265891e --- /dev/null +++ b/features/preferences/impl/src/main/res/values/temporary.xml @@ -0,0 +1,18 @@ + + + + In a meeting + Focus time + On the road + Be right back + Away + On a call + Custom… + What\'s your status? + Custom status… + diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index a6861be12d4..0bda80fe0ca 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.core.meta.BuildType @@ -346,5 +347,6 @@ class PreferencesRootPresenterTest { featureFlagService = featureFlagService, sessionStore = sessionStore, sessionEnterpriseService = sessionEnterpriseService, + userStatusPresenter = { aUserStatusState() }, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index aded0970596..bf8bd78bf2c 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -12,8 +12,11 @@ package io.element.android.features.preferences.impl.root import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.MatrixUser @@ -199,7 +202,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenAnalytics = callback, ) - clickOn(CommonStrings.common_analytics) + val text = activity!!.getString(CommonStrings.common_analytics) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -226,7 +230,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenRageShake = callback, ) - clickOn(CommonStrings.common_report_a_problem) + val text = activity!!.getString(CommonStrings.common_report_a_problem) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -281,7 +286,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenDeveloperSettings = callback, ) - clickOn(CommonStrings.common_developer_options) + val text = activity!!.getString(CommonStrings.common_developer_options) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -389,7 +395,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onSignOutClick = callback, ) - clickOn(CommonStrings.action_signout) + val text = activity!!.getString(CommonStrings.action_signout) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -404,7 +411,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onDeactivateClick = callback, ) - clickOn(CommonStrings.action_delete_account) + val text = activity!!.getString(CommonStrings.action_delete_account) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -430,7 +438,7 @@ class PreferencesRootViewTest : RobolectricTest() { eventSink = eventsRecorder, ), ) - onNodeWithText(version).performClick() + onNodeWithText(version).performScrollTo().performClick() eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt new file mode 100644 index 00000000000..3f78843b74e --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.userstatus + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UserStatusPresenterTest { + private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) = + UserStatusPresenter(matrixClient = client) + + @Test + fun `initial state - no status set`() = runTest { + createPresenter().test { + val state = awaitItem() + assertThat(state.displayedStatus).isNull() + assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden) + } + } + + @Test + fun `Open event transitions to ShowingPicker`() = runTest { + createPresenter().test { + val state = awaitItem() + state.eventSink(UserStatusEvent.OpenPicker) + assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker) + } + } + + @Test + fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest { + createPresenter().test { + val state = awaitItem() + state.eventSink(UserStatusEvent.OpenPicker) + awaitItem() // ShowingPicker + state.eventSink(UserStatusEvent.DismissPicker) + assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden) + } + } + + @Test + fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest { + val client = FakeMatrixClient() + createPresenter(client).test { + val status = UserStatus(emoji = "πŸ’¬", text = "In a meeting") + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) + val newState = awaitItem() + assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) + assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status)) + assertThat(client.setUserStatusCalled).isTrue() + } + } + + @Test + fun `OpenCustomInput with no existing status uses defaults`() = runTest { + createPresenter().test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + val state = awaitItem() + val pickerState = state.pickerState as UserStatusPickerState.CustomInput + assertThat(pickerState.emoji).isEqualTo("πŸ˜€") + assertThat(pickerState.textFieldState.text.toString()).isEqualTo("") + } + } + + @Test + fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest { + val client = FakeMatrixClient() + createPresenter(client).test { + val status = UserStatus(emoji = "🌴", text = "Away") + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) + // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + val state = awaitItem() + val pickerState = state.pickerState as UserStatusPickerState.CustomInput + assertThat(pickerState.emoji).isEqualTo("🌴") + assertThat(pickerState.textFieldState.text.toString()).isEqualTo("Away") + } + } + + @Test + fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest { + createPresenter().test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("πŸš€")) + val state = awaitItem() + assertThat((state.pickerState as UserStatusPickerState.CustomInput).emoji).isEqualTo("πŸš€") + } + } + + @Test + fun `CancelCustomInput goes back to Hidden`() = runTest { + createPresenter().test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + awaitItem().eventSink(UserStatusEvent.CancelCustomInput) // CustomInput state, fire Cancel + assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden) + } + } + + @Test + fun `Clear event calls clearUserStatus and clears displayed status`() = runTest { + val client = FakeMatrixClient() + createPresenter(client).test { + val status = UserStatus(emoji = "β˜•", text = "Be right back") + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) + // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition + awaitItem().eventSink(UserStatusEvent.ClearStatus) + val clearedState = awaitItem() + assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) + assertThat(clearedState.displayedStatus).isNull() + assertThat(client.clearUserStatusCalled).isTrue() + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt index 5e0a81ba031..d4e08eacf18 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -24,6 +24,11 @@ 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 +import androidx.compose.foundation.text.input.TextFieldDecorator +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -161,6 +166,55 @@ fun TextField( } } +@Composable +fun TextField( + state: TextFieldState, + modifier: Modifier = Modifier, + label: String? = null, + supportingText: String? = null, + placeholder: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + validity: TextFieldValidity = TextFieldValidity.None, + enabled: Boolean = true, + readOnly: Boolean = false, + inputTransformation: InputTransformation? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + onKeyboardAction: KeyboardActionHandler? = null, + lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val isFocused by interactionSource.collectIsFocusedAsState() + BasicTextField( + state = state, + modifier = modifier, + textStyle = textFieldStyle(enabled), + interactionSource = interactionSource, + enabled = enabled, + readOnly = readOnly, + inputTransformation = inputTransformation, + cursorBrush = SolidColor(ElementTheme.colors.textPrimary), + keyboardOptions = keyboardOptions, + onKeyboardAction = onKeyboardAction, + lineLimits = lineLimits, + decorator = TextFieldDecorator { innerTextField -> + DecorationBox( + label = label, + readOnly = readOnly, + enabled = enabled, + isFocused = isFocused, + validity = validity, + leadingIcon = leadingIcon, + placeholder = placeholder, + isTextEmpty = state.text.isEmpty(), + innerTextField = innerTextField, + trailingIcon = trailingIcon, + supportingText = supportingText, + ) + } + ) +} + @Composable private fun DecorationBox( label: String?, diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 29c7be3a3d9..ab7454e72f5 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -152,4 +152,11 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + UserStatus( + key = "feature.user_status", + title = "User status", + description = "Allow users to set a status (e.g. In a meeting, Away) visible to their contacts.", + defaultValue = { false }, + isFinished = false, + ), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 8f9c6601cac..1e14873b978 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.verification.SessionVerificationService import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope @@ -86,6 +87,10 @@ interface MatrixClient { suspend fun setDisplayName(displayName: String): Result suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result suspend fun removeAvatar(): Result + suspend fun setUserStatus(status: UserStatus): Result + + /** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */ + suspend fun clearUserStatus(): Result suspend fun joinRoom(roomId: RoomId): Result suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt new file mode 100644 index 00000000000..581658ff28c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.user + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize + +@Immutable +sealed interface DisplayedStatus : Parcelable { + /** Status set manually by the user via m.status. */ + @Parcelize + data class UserSet(val status: UserStatus) : DisplayedStatus + + /** Status set automatically when the user is in a call via m.call. */ + @Parcelize + data class InCall( + /** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */ + val callJoinedTs: Long, + ) : DisplayedStatus +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt index 53cefa36941..7a8ec739a5c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt @@ -17,4 +17,6 @@ data class MatrixUser( val userId: UserId, val displayName: String? = null, val avatarUrl: String? = null, + val displayedStatus: DisplayedStatus? = null, + val rawStatus: UserStatus? = null, ) : Parcelable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/UserStatus.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/UserStatus.kt new file mode 100644 index 00000000000..1576940b6f2 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/UserStatus.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.user + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class UserStatus( + val emoji: String, + val text: String, +) : Parcelable diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 5c81a3f0855..6ddca47bff1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -49,8 +49,10 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler @@ -137,6 +139,7 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService +@Suppress("LargeClass") class RustMatrixClient( override val sessionPaths: SessionPaths, private val innerClient: Client, @@ -260,6 +263,8 @@ class RustMatrixClient( private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) + @Volatile private var localUserStatus: UserStatus? = null + private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( userId = sessionId, @@ -445,7 +450,12 @@ class RustMatrixClient( override suspend fun getUserProfile(): Result = getProfile(sessionId) .onSuccess { matrixUser -> - _userProfile.emit(matrixUser) + _userProfile.emit( + matrixUser.copy( + rawStatus = localUserStatus, + displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) }, + ) + ) // Also update our session storage sessionStore.updateUserProfile( sessionId = sessionId.value, @@ -476,6 +486,28 @@ class RustMatrixClient( runCatchingExceptions { innerClient.removeAvatar() } } + override suspend fun setUserStatus(status: UserStatus): Result = withContext(sessionDispatcher) { + localUserStatus = status + _userProfile.emit( + _userProfile.value.copy( + rawStatus = status, + displayedStatus = DisplayedStatus.UserSet(status), + ) + ) + Result.success(Unit) + } + + override suspend fun clearUserStatus(): Result = withContext(sessionDispatcher) { + localUserStatus = null + _userProfile.emit( + _userProfile.value.copy( + rawStatus = null, + displayedStatus = null, + ) + ) + Result.success(Unit) + } + override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) { runCatchingExceptions { innerClient.joinRoomById(roomId.value).destroy() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 98fe01553cb..3455ee2b2d4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -42,8 +42,10 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader @@ -131,6 +133,12 @@ class FakeMatrixClient( private set var removeAvatarCalled: Boolean = false private set + var setUserStatusCalled: Boolean = false + private set + var clearUserStatusCalled: Boolean = false + private set + var setUserStatusResult: Result = Result.success(Unit) + var clearUserStatusResult: Result = Result.success(Unit) private val _userProfile: MutableStateFlow = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl)) override val userProfile: StateFlow = _userProfile @@ -253,6 +261,28 @@ class FakeMatrixClient( return removeAvatarResult } + override suspend fun setUserStatus(status: UserStatus): Result { + setUserStatusCalled = true + if (setUserStatusResult.isSuccess) { + _userProfile.emit(_userProfile.value.copy( + rawStatus = status, + displayedStatus = DisplayedStatus.UserSet(status), + )) + } + return setUserStatusResult + } + + override suspend fun clearUserStatus(): Result { + clearUserStatusCalled = true + if (clearUserStatusResult.isSuccess) { + _userProfile.emit(_userProfile.value.copy( + rawStatus = null, + displayedStatus = null, + )) + } + return clearUserStatusResult + } + override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId) override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result { diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png index 6e46211c3e1..4e4f3b0fd3c 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cff89dace11868ae57ad7c701303ab19096be52bbc21f7b43f91097fe8a612f1 -size 40300 +oid sha256:4b3d5527423e0bb16a65fc9981db06b7e5eaef2736e68833dd91aae5825b9126 +size 40439 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png index 053b0cf1f6c..312a9bad45b 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:92420711d5445edf625ff3125ecabbfbd4317dda6d7e29b4484a3b38d8d3193d -size 24297 +oid sha256:941f79f738e902fcca7e3ec2f71acc4c230532bdb2c9136a2e7420ac88106ba4 +size 27372 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png index 2c9cc8de20b..32610f27d2c 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f36ead42cf5f79023ca960698b25864e44a098b71d4de8a4fbff6e2cb0a86b99 -size 35546 +oid sha256:dc80aa470630ec356274dec6abd0f05149506f4bb1c1b495a4ad8ee4efc8ac69 +size 38510 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png index 2d0012240f1..08dc4d97cc4 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:83085cec3cd4e3a8eb9c8b112d54d9aa1eaa6ca3206b5f526b6e0edc7392ce92 -size 26743 +oid sha256:a89c8eeebf6cba350daa4940ea8248148286341bf19a4b36f63330ab508bfa20 +size 29985 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png index a89b3d05f52..9c32e85b15e 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a9739c8908859f2401d118e32f4354a2132d6d08173c4e5683eaa1b8dee8cc5 -size 26897 +oid sha256:b5743d92c92425093ddeff2af31d8c3a803a029711aed1712c7cbc50890b0d77 +size 29992 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png index d5f5b7f6ca2..f6b76e38e7c 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0d9335e3b6900a366d4770849736b7bc866bb205c9b6f004a3521d6a058916b -size 20020 +oid sha256:7c75ad887209b88e700c6ad53090ed4c2d7e5a3f49836608999e9a3124a63fc0 +size 32869 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_6_en.png new file mode 100644 index 00000000000..273cf6c7811 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:837ebeb30aebfb154e98b1b4603156cf0ac877b58efae17d534aaf269ce8ba10 +size 20037 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png index d527233adca..a1906506cec 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a911c0f75b2146fffb24d3bddac02ae096eac871285ef15c4d1477629a5b2d49 -size 41390 +oid sha256:50da5b2b2851c4b13daadc44e81beaf19294985fb4c6e37caf2105f0bddc6bb6 +size 41628 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png index 77170a40ca7..8b41f74ad9f 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dee0eff6123dca9f9b3ba544d2db16b1e2ab982d20a5d8a0013b0e08e37afddd -size 25094 +oid sha256:17fc2a4024aec430ca302e915f951c231d6f8c66de9030bc8acf64954cbd8d8b +size 28424 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png index 7be86a87577..09a752bb664 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6fef2b0dd09457de0079e3c00ea6e07a81bfb7d0f500d228d38c9054b446708f -size 36271 +oid sha256:b766642d069f44c06f7a2bf74dba24070fc3260cb77cc9658d163d971b9573d4 +size 39408 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png index 046163551ac..a95a91b7319 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a8ef73ae13ff39f1612dde7582225a18656015c2608fa45510bef3d8a52a705 -size 27701 +oid sha256:db069c64c97811f2b311bb63c8dbb9f78f534bdb3475d7b433412350445ec894 +size 30937 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png index 1c8583ea337..ffa813f1acd 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:691bdc37a68ad14bf1ac08ff242861412915c597017b7980850fb2a82070c228 -size 27646 +oid sha256:66f3f2d1f043603f36187c2ea1e86f40628b23f0aed69f90e2452dbbae558133 +size 30888 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png index 5c6556e3b3d..13957a0450f 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41c88c94e910f524567d7054d7914eeddccff2b0249e331582ea1ab130d40d6a -size 20454 +oid sha256:f12abebdcc42404c0a5719c490f5da4d3d5a1986d469884773bf9ed8071dd27b +size 34154 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_6_en.png new file mode 100644 index 00000000000..d77f3adbf91 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07ad7e092d6b8e41064bcbce131ef53d70a552dd6f9292dd286dd7c10948cf9a +size 20459 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_0_en.png new file mode 100644 index 00000000000..d98e3fd4759 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c14e7ad5b7c320873dab0b97a6261b2b62a7405d133e9c53fe2184e9757becac +size 9203 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png new file mode 100644 index 00000000000..0607484ce06 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35f0fb99a74477ebfa53a019ded68043035de9ba0f86a6a71b032137a125d107 +size 7168 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png new file mode 100644 index 00000000000..9f77167b6e2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6432133a87271013cab796baa7d48ab8e96f65f049b92a9074ec03f540e11545 +size 7269 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png new file mode 100644 index 00000000000..470324716e4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebc88a04da18e07b5f94cc52f75ad033fa01917de6c32ac77a1b400fd27c62cd +size 28944 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png new file mode 100644 index 00000000000..56c7646b1b7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cff5ee1c0cc217b20c68bb4f4d2335ab03e9dd8d16045550358e748002c538b +size 27460 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png new file mode 100644 index 00000000000..1f73455d54f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5dc5c492ed3fd552e77c0be85b639d55bc7e976f15b761f927b0b80ecca601d +size 12115 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png new file mode 100644 index 00000000000..0ca6f664380 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5896eb5df6f5398bdb94e74280d0d5c04b6a5a53c2378b7377c60fa58eef544b +size 12013 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_0_en.png new file mode 100644 index 00000000000..00a43265e20 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:343ddb8692135fb051766c340a4ac6143a5dc57917f0cc16fe11678f2b9bae6e +size 8774 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png new file mode 100644 index 00000000000..b6c2706180c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4b5eb36080cc5750c4cd36ae66ba618c033e794fdf0a4fb5afbae5e1fa10824 +size 6789 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png new file mode 100644 index 00000000000..0b65cb998ba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf614dc03afdb990baae40b020bd3f3d6396226b45fc4eee18f2d55100df5bff +size 6918 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png new file mode 100644 index 00000000000..2aca39dc8d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fff529404e3219d00ded2f701066c48506b96512b08b1750665d3c2c0a75ed97 +size 27700 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png new file mode 100644 index 00000000000..5ff2f9b93de --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1017de34abdb57805946efdd6d486ab76adecdc458d17eb9b1128be0dbe8ff00 +size 26227 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png new file mode 100644 index 00000000000..a6872411518 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7ce8a8c6b4d1692e4b127b7f17f507552666c631893025082c385b93d835a73 +size 11726 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png new file mode 100644 index 00000000000..a1d1c5ec8e2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b93c08531efbeacb55f84b17ac793a7b226e5a2db7bc5072e94bc040cd1e9bc +size 11386