From bad32cbbda7dbcba7ddb22f3ddaf10e276d85c1f Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:47:21 +0200 Subject: [PATCH 01/30] Add UserStatus and DisplayedStatus types to matrix API --- .../libraries/matrix/api/MatrixClient.kt | 3 +++ .../matrix/api/user/DisplayedStatus.kt | 21 +++++++++++++++++++ .../libraries/matrix/api/user/MatrixUser.kt | 2 ++ .../libraries/matrix/api/user/UserStatus.kt | 17 +++++++++++++++ 4 files changed, 43 insertions(+) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/UserStatus.kt 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 8f482440e84..4147333565c 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 @@ -44,6 +44,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 @@ -84,6 +85,8 @@ 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 + 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..1b1dd551ff1 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.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.libraries.matrix.api.user + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +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(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 From 5697c9664f58a6194ce47f099d5b80d8b70e2c5b Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:49:57 +0200 Subject: [PATCH 02/30] Add KDoc to InCall.callJoinedTs and clearUserStatus --- .../io/element/android/libraries/matrix/api/MatrixClient.kt | 1 + .../android/libraries/matrix/api/user/DisplayedStatus.kt | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) 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 4147333565c..001bac0d30b 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 @@ -86,6 +86,7 @@ interface MatrixClient { 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 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 index 1b1dd551ff1..046191d7cf5 100644 --- 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 @@ -17,5 +17,8 @@ sealed interface DisplayedStatus : Parcelable { /** Status set automatically when the user is in a call via m.call. */ @Parcelize - data class InCall(val callJoinedTs: Long) : DisplayedStatus + data class InCall( + /** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */ + val callJoinedTs: Long, + ) : DisplayedStatus } From 742902ef02e753fe4e051b3aaa0f58dc1820c448 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:52:51 +0200 Subject: [PATCH 03/30] Add setUserStatus/clearUserStatus stubs to FakeMatrixClient --- .../libraries/matrix/test/FakeMatrixClient.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 cce99e9d090..dce089537f2 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 @@ -41,8 +41,10 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService 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 @@ -128,6 +130,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 @@ -250,6 +258,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 { From f4c79be3285fb74a0db202ba720d41cfb3336951 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:52:51 +0200 Subject: [PATCH 04/30] Stub setUserStatus/clearUserStatus in-memory in RustMatrixClient --- .../libraries/matrix/impl/RustMatrixClient.kt | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) 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 9167b3e2f37..37badadcd40 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 @@ -48,8 +48,10 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService 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 @@ -258,6 +260,8 @@ class RustMatrixClient( private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) + private var localUserStatus: UserStatus? = null + private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( userId = sessionId, @@ -443,8 +447,12 @@ class RustMatrixClient( override suspend fun getUserProfile(): Result = getProfile(sessionId) .onSuccess { matrixUser -> - _userProfile.emit(matrixUser) - // Also update our session storage + _userProfile.emit( + matrixUser.copy( + rawStatus = localUserStatus, + displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) }, + ) + ) sessionStore.updateUserProfile( sessionId = sessionId.value, displayName = matrixUser.displayName, @@ -474,6 +482,28 @@ class RustMatrixClient( runCatchingExceptions { innerClient.removeAvatar() } } + override suspend fun setUserStatus(status: UserStatus): Result { + localUserStatus = status + _userProfile.emit( + _userProfile.value.copy( + rawStatus = status, + displayedStatus = DisplayedStatus.UserSet(status), + ) + ) + return Result.success(Unit) + } + + override suspend fun clearUserStatus(): Result { + localUserStatus = null + _userProfile.emit( + _userProfile.value.copy( + rawStatus = null, + displayedStatus = null, + ) + ) + return Result.success(Unit) + } + override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) { runCatchingExceptions { innerClient.joinRoomById(roomId.value).destroy() From 42966b393f4c20632236c5c0035f78bee91c90b0 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:52:51 +0200 Subject: [PATCH 05/30] Add user status string resources and PredefinedUserStatus enum --- .../impl/userstatus/PredefinedUserStatus.kt | 19 +++++++++++++++++++ .../impl/src/main/res/values/temporary.xml | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt create mode 100644 features/preferences/impl/src/main/res/values/temporary.xml diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt new file mode 100644 index 00000000000..78fec19f7ac --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt @@ -0,0 +1,19 @@ +/* + * 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 io.element.android.features.preferences.impl.R + +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/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… + From 1640adcf9d8e9b2006e552be5ccd856d0614c317 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:56:41 +0200 Subject: [PATCH 06/30] Fix thread-safety and dispatcher consistency in user status stubs --- .../android/libraries/matrix/impl/RustMatrixClient.kt | 10 +++++----- .../android/libraries/matrix/test/FakeMatrixClient.kt | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) 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 37badadcd40..92107f3ac29 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 @@ -260,7 +260,7 @@ class RustMatrixClient( private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) - private var localUserStatus: UserStatus? = null + @Volatile private var localUserStatus: UserStatus? = null private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( @@ -482,7 +482,7 @@ class RustMatrixClient( runCatchingExceptions { innerClient.removeAvatar() } } - override suspend fun setUserStatus(status: UserStatus): Result { + override suspend fun setUserStatus(status: UserStatus): Result = withContext(sessionDispatcher) { localUserStatus = status _userProfile.emit( _userProfile.value.copy( @@ -490,10 +490,10 @@ class RustMatrixClient( displayedStatus = DisplayedStatus.UserSet(status), ) ) - return Result.success(Unit) + Result.success(Unit) } - override suspend fun clearUserStatus(): Result { + override suspend fun clearUserStatus(): Result = withContext(sessionDispatcher) { localUserStatus = null _userProfile.emit( _userProfile.value.copy( @@ -501,7 +501,7 @@ class RustMatrixClient( displayedStatus = null, ) ) - return Result.success(Unit) + Result.success(Unit) } override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) { 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 dce089537f2..a13b4b6ad65 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 @@ -258,7 +258,7 @@ class FakeMatrixClient( return removeAvatarResult } - override suspend fun setUserStatus(status: UserStatus): Result { + override suspend fun setUserStatus(status: UserStatus): Result = simulateLongTask { setUserStatusCalled = true if (setUserStatusResult.isSuccess) { _userProfile.emit(_userProfile.value.copy( @@ -269,7 +269,7 @@ class FakeMatrixClient( return setUserStatusResult } - override suspend fun clearUserStatus(): Result { + override suspend fun clearUserStatus(): Result = simulateLongTask { clearUserStatusCalled = true if (clearUserStatusResult.isSuccess) { _userProfile.emit(_userProfile.value.copy( From eca45b574eab879fffc8062d046436fcbfff7352 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:57:39 +0200 Subject: [PATCH 07/30] Add UserStatusState and UserStatusEvent --- .../impl/userstatus/UserStatusEvent.kt | 25 +++++++++++++++++++ .../impl/userstatus/UserStatusState.kt | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt 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..703f8dadaec --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt @@ -0,0 +1,25 @@ +/* + * 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 { + /** User tapped the status row β€” open the picker bottom sheet. */ + data object Open : UserStatusEvent + /** User dismissed the bottom sheet without selecting. */ + data object Dismiss : UserStatusEvent + /** User selected a predefined or confirmed a custom status. */ + data class Set(val status: UserStatus) : UserStatusEvent + /** User tapped "Custom…" in the bottom sheet. */ + data object OpenCustomInput : UserStatusEvent + /** User tapped Cancel in the inline custom input row. */ + data object CancelCustomInput : UserStatusEvent + /** User tapped the clear action on an existing status. */ + data object Clear : UserStatusEvent +} 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..5ae15377584 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -0,0 +1,25 @@ +/* + * 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.DisplayedStatus + +data class UserStatusState( + val displayedStatus: DisplayedStatus?, + val pickerState: UserStatusPickerState, + val eventSink: (UserStatusEvent) -> Unit, +) + +sealed interface UserStatusPickerState { + data object Hidden : UserStatusPickerState + data object ShowingPicker : UserStatusPickerState + data class CustomInput( + val initialEmoji: String = "πŸ˜€", + val initialText: String = "", + ) : UserStatusPickerState +} From 0528b787c90d0a4fa23635aca5476dd3c1d6c616 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 15:37:28 +0200 Subject: [PATCH 08/30] Add UserStatusPresenter with full test coverage --- .../impl/userstatus/UserStatusPresenter.kt | 63 +++++++++ .../userstatus/UserStatusPresenterTest.kt | 132 ++++++++++++++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 4 +- 3 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt 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..a20bcd1b568 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -0,0 +1,63 @@ +/* + * 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.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 io.element.android.libraries.matrix.api.user.UserStatus +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 coroutineScope = rememberCoroutineScope() + + fun handleEvent(event: UserStatusEvent) { + when (event) { + UserStatusEvent.Open -> pickerState = UserStatusPickerState.ShowingPicker + UserStatusEvent.Dismiss -> pickerState = UserStatusPickerState.Hidden + UserStatusEvent.OpenCustomInput -> { + val raw = userProfile.rawStatus + pickerState = UserStatusPickerState.CustomInput( + initialEmoji = raw?.emoji ?: "πŸ˜€", + initialText = raw?.text ?: "", + ) + } + is UserStatusEvent.Set -> { + pickerState = UserStatusPickerState.Hidden + coroutineScope.launch { matrixClient.setUserStatus(event.status) } + } + UserStatusEvent.Clear -> { + pickerState = UserStatusPickerState.Hidden + coroutineScope.launch { matrixClient.clearUserStatus() } + } + UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden + } + } + + return UserStatusState( + displayedStatus = userProfile.displayedStatus, + pickerState = pickerState, + eventSink = ::handleEvent, + ) + } +} 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..d8187462f85 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -0,0 +1,132 @@ +/* + * 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 app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +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 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 { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + val state = awaitItem() + assertThat(state.displayedStatus).isNull() + assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden) + } + } + + @Test + fun `Open event transitions to ShowingPicker`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + val state = awaitItem() + state.eventSink(UserStatusEvent.Open) + assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker) + } + } + + @Test + fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + val state = awaitItem() + state.eventSink(UserStatusEvent.Open) + awaitItem() // ShowingPicker + state.eventSink(UserStatusEvent.Dismiss) + assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden) + } + } + + @Test + fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest { + val client = FakeMatrixClient() + moleculeFlow(RecompositionMode.Immediate) { + createPresenter(client).present() + }.test { + val status = UserStatus(emoji = "πŸ’¬", text = "In a meeting") + awaitItem().eventSink(UserStatusEvent.Set(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 { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + val state = awaitItem() + val pickerState = state.pickerState as UserStatusPickerState.CustomInput + assertThat(pickerState.initialEmoji).isEqualTo("πŸ˜€") + assertThat(pickerState.initialText).isEqualTo("") + } + } + + @Test + fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest { + val client = FakeMatrixClient() + moleculeFlow(RecompositionMode.Immediate) { + createPresenter(client).present() + }.test { + val status = UserStatus(emoji = "🌴", text = "Away") + awaitItem().eventSink(UserStatusEvent.Set(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.initialEmoji).isEqualTo("🌴") + assertThat(pickerState.initialText).isEqualTo("Away") + } + } + + @Test + fun `CancelCustomInput goes back to Hidden`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.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() + moleculeFlow(RecompositionMode.Immediate) { + createPresenter(client).present() + }.test { + val status = UserStatus(emoji = "β˜•", text = "Be right back") + awaitItem().eventSink(UserStatusEvent.Set(status)) + // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition + awaitItem().eventSink(UserStatusEvent.Clear) + val clearedState = awaitItem() + assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) + assertThat(clearedState.displayedStatus).isNull() + assertThat(client.clearUserStatusCalled).isTrue() + } + } +} 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 a13b4b6ad65..dce089537f2 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 @@ -258,7 +258,7 @@ class FakeMatrixClient( return removeAvatarResult } - override suspend fun setUserStatus(status: UserStatus): Result = simulateLongTask { + override suspend fun setUserStatus(status: UserStatus): Result { setUserStatusCalled = true if (setUserStatusResult.isSuccess) { _userProfile.emit(_userProfile.value.copy( @@ -269,7 +269,7 @@ class FakeMatrixClient( return setUserStatusResult } - override suspend fun clearUserStatus(): Result = simulateLongTask { + override suspend fun clearUserStatus(): Result { clearUserStatusCalled = true if (clearUserStatusResult.isSuccess) { _userProfile.emit(_userProfile.value.copy( From af5a237b4ac11d1f0312c05c5e738292fe55a29e Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 15:37:28 +0200 Subject: [PATCH 09/30] Add UserStatusView composables with previews --- .../impl/userstatus/UserStatusView.kt | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt 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..78f96042e16 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -0,0 +1,246 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 UserStatusRow( + 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.Open) }, + onClear = { state.eventSink(UserStatusEvent.Clear) }, + modifier = modifier, + ) + } else { + EmptyStatusRow( + onClick = { state.eventSink(UserStatusEvent.Open) }, + modifier = modifier, + ) + } + if (pickerState == UserStatusPickerState.ShowingPicker) { + UserStatusPickerBottomSheet( + onDismiss = { state.eventSink(UserStatusEvent.Dismiss) }, + onSelectPredefined = { status -> state.eventSink(UserStatusEvent.Set(status)) }, + onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, + ) + } + } + is UserStatusPickerState.CustomInput -> { + CustomStatusInputRow( + initialEmoji = pickerState.initialEmoji, + initialText = pickerState.initialText, + onConfirm = { emoji, text -> + state.eventSink(UserStatusEvent.Set(UserStatus(emoji, text))) + }, + 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), + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = { + Text(text = "πŸ™‚", style = ElementTheme.typography.fontBodyLgRegular) + }, + 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) }, + leadingContent = { + Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) + }, + trailingContent = { + TextButton(onClick = onClear) { + Text(text = stringResource(CommonStrings.action_clear)) + } + }, + modifier = modifier.clickable(onClick = onClick), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UserStatusPickerBottomSheet( + onDismiss: () -> Unit, + onSelectPredefined: (UserStatus) -> Unit, + onSelectCustom: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + PredefinedUserStatus.entries.forEach { predefined -> + val label = stringResource(predefined.labelRes) + ListItem( + headlineContent = { Text(text = label) }, + leadingContent = { + Text(text = predefined.emoji, style = ElementTheme.typography.fontBodyLgRegular) + }, + modifier = Modifier.clickable { + onSelectPredefined(UserStatus(emoji = predefined.emoji, text = label)) + }, + ) + } + ListItem( + headlineContent = { + Text(text = stringResource(R.string.common_user_status_custom)) + }, + leadingContent = { + Text(text = "✏️", style = ElementTheme.typography.fontBodyLgRegular) + }, + modifier = Modifier.clickable(onClick = onSelectCustom), + ) + } +} + +@Composable +private fun CustomStatusInputRow( + initialEmoji: String, + initialText: String, + onConfirm: (emoji: String, text: String) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + var emoji by remember { mutableStateOf(initialEmoji) } + var text by remember { mutableStateOf(initialText) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Surface( + shape = CircleShape, + color = ElementTheme.colors.bgSubtleSecondary, + modifier = Modifier + .size(40.dp) + .clickable { }, + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) + } + } + OutlinedTextField( + value = text, + onValueChange = { if (it.length <= 30) text = it }, + placeholder = { + Text(text = stringResource(R.string.screen_preferences_user_status_custom_hint)) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { if (text.isNotBlank()) onConfirm(emoji, text) } + ), + singleLine = true, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + TextButton(onClick = onCancel) { + Text(text = stringResource(CommonStrings.action_cancel)) + } + } +} + +@PreviewsDayNight +@Composable +internal fun UserStatusRowEmptyPreview() = ElementPreview { + UserStatusRow( + state = UserStatusState( + displayedStatus = null, + pickerState = UserStatusPickerState.Hidden, + eventSink = {}, + ) + ) +} + +@PreviewsDayNight +@Composable +internal fun UserStatusRowSetPreview() = ElementPreview { + UserStatusRow( + state = UserStatusState( + displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")), + pickerState = UserStatusPickerState.Hidden, + eventSink = {}, + ) + ) +} + +@PreviewsDayNight +@Composable +internal fun UserStatusRowCustomInputPreview() = ElementPreview { + UserStatusRow( + state = UserStatusState( + displayedStatus = null, + pickerState = UserStatusPickerState.CustomInput(initialEmoji = "πŸ˜€", initialText = ""), + eventSink = {}, + ) + ) +} From 6c34d647ca9056b5126f3d799c6905f3a2d43f09 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 15:48:54 +0200 Subject: [PATCH 10/30] Wire UserStatusPresenter and UserStatusRow into PreferencesRoot --- .../impl/root/PreferencesRootPresenter.kt | 4 ++++ .../impl/root/PreferencesRootState.kt | 2 ++ .../impl/root/PreferencesRootStateProvider.kt | 4 ++++ .../impl/root/PreferencesRootView.kt | 11 +++++++--- .../userstatus/UserStatusStateProvider.kt | 20 +++++++++++++++++++ .../impl/root/PreferencesRootPresenterTest.kt | 2 ++ .../impl/root/PreferencesRootViewTest.kt | 11 +++++++--- 7 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt 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..fa69382cf8a 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,9 +58,11 @@ class PreferencesRootPresenter( private val featureFlagService: FeatureFlagService, private val sessionStore: SessionStore, private val sessionEnterpriseService: SessionEnterpriseService, + private val userStatusPresenter: Presenter, ) : Presenter { @Composable override fun present(): PreferencesRootState { + val userStatusState = userStatusPresenter.present() val coroutineScope = rememberCoroutineScope() val matrixUser = matrixClient.userProfile.collectAsState() LaunchedEffect(Unit) { @@ -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..8163e4b7a0e 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..9e1557fd7b4 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,8 @@ 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.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 @@ -86,6 +88,7 @@ fun aPreferencesRootState( nbOfBlockedUsers: Int = 0, showLabsItem: Boolean = false, directLogoutState: DirectLogoutState = aDirectLogoutState(), + userStatusState: UserStatusState = aUserStatusState(), snackbarMessage: SnackbarMessage? = null, eventSink: (PreferencesRootEvent) -> Unit = {}, ) = PreferencesRootState( @@ -105,6 +108,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..d92c1e4613a 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,7 @@ 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.UserStatusRow 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 @@ -80,15 +82,18 @@ fun PreferencesRootView( }, matrixUser = state.myUser, ) + HorizontalDivider() + UserStatusRow( + state = state.userStatusState, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider() if (state.isMultiAccountEnabled) { MultiAccountSection( state = state, onAddAccountClick = onAddAccountClick, ) - } else { - HorizontalDivider() } - // User status will be added here // 'Account' section ManageAccountSection( state = state, 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..65ec264cc4c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.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.DisplayedStatus + +fun aUserStatusState( + displayedStatus: DisplayedStatus? = null, + pickerState: UserStatusPickerState = UserStatusPickerState.Hidden, + eventSink: (UserStatusEvent) -> Unit = {}, +) = UserStatusState( + displayedStatus = displayedStatus, + pickerState = pickerState, + eventSink = eventSink, +) 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..5c9d81875e9 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 @@ -281,7 +284,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() } } @@ -404,7 +408,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 +435,7 @@ class PreferencesRootViewTest : RobolectricTest() { eventSink = eventsRecorder, ), ) - onNodeWithText(version).performClick() + onNodeWithText(version).performScrollTo().performClick() eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick) } } From 7309e49286235fa090b33c9bd1f6ff8598e0d1f7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 16:04:42 +0200 Subject: [PATCH 11/30] Wire UserStatusPresenter and UserStatusRow into PreferencesRoot --- .../impl/userstatus/di/UserStatusModule.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt new file mode 100644 index 00000000000..b2be2d75d47 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt @@ -0,0 +1,23 @@ +/* + * 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.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.preferences.impl.userstatus.UserStatusPresenter +import io.element.android.features.preferences.impl.userstatus.UserStatusState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@BindingContainer +interface UserStatusModule { + @Binds + fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter +} From eeaaac296995ebbd6ff3a7f5cd3d0c1217e175e8 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 15 Jun 2026 15:22:54 +0200 Subject: [PATCH 12/30] Move custom status emoji/text into CustomInput state (MVI) --- .../impl/userstatus/PredefinedUserStatus.kt | 19 --- .../impl/userstatus/UserStatusEvent.kt | 4 + .../impl/userstatus/UserStatusPresenter.kt | 16 ++- .../impl/userstatus/UserStatusState.kt | 14 +- .../impl/userstatus/UserStatusView.kt | 130 ++++++++++-------- .../impl/userstatus/di/UserStatusModule.kt | 23 ---- .../userstatus/UserStatusPresenterTest.kt | 32 ++++- 7 files changed, 133 insertions(+), 105 deletions(-) delete mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt delete mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt deleted file mode 100644 index 78fec19f7ac..00000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 io.element.android.features.preferences.impl.R - -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/UserStatusEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt index 703f8dadaec..d78be1607bf 100644 --- 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 @@ -20,6 +20,10 @@ sealed interface UserStatusEvent { data object OpenCustomInput : UserStatusEvent /** User tapped Cancel in the inline custom input row. */ data object CancelCustomInput : UserStatusEvent + /** User changed the emoji in the custom input row. */ + data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent + /** User changed the text in the custom input row. */ + data class UpdateCustomText(val text: String) : UserStatusEvent /** User tapped the clear action on an existing status. */ data object Clear : 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 index a20bcd1b568..d765f358008 100644 --- 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 @@ -14,13 +14,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.user.UserStatus import kotlinx.coroutines.launch -@Inject +@ContributesBinding(SessionScope::class) class UserStatusPresenter( private val matrixClient: MatrixClient, ) : Presenter { @@ -38,8 +40,8 @@ class UserStatusPresenter( UserStatusEvent.OpenCustomInput -> { val raw = userProfile.rawStatus pickerState = UserStatusPickerState.CustomInput( - initialEmoji = raw?.emoji ?: "πŸ˜€", - initialText = raw?.text ?: "", + emoji = raw?.emoji ?: "πŸ˜€", + text = raw?.text ?: "", ) } is UserStatusEvent.Set -> { @@ -51,6 +53,14 @@ class UserStatusPresenter( 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) + } + is UserStatusEvent.UpdateCustomText -> { + val current = pickerState as? UserStatusPickerState.CustomInput ?: return + pickerState = current.copy(text = event.text) + } } } 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 index 5ae15377584..df1a97a5e3a 100644 --- 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 @@ -7,6 +7,8 @@ package io.element.android.features.preferences.impl.userstatus +import androidx.annotation.StringRes +import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.DisplayedStatus data class UserStatusState( @@ -19,7 +21,15 @@ sealed interface UserStatusPickerState { data object Hidden : UserStatusPickerState data object ShowingPicker : UserStatusPickerState data class CustomInput( - val initialEmoji: String = "πŸ˜€", - val initialText: String = "", + val emoji: String = "πŸ˜€", + val text: String = "", ) : UserStatusPickerState } + +enum class PredefinedUserStatus(val emoji: String, @param: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/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 78f96042e16..956400fa632 100644 --- 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 @@ -7,6 +7,7 @@ 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.Row @@ -17,26 +18,29 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction 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.Button +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.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @@ -72,11 +76,11 @@ fun UserStatusRow( } is UserStatusPickerState.CustomInput -> { CustomStatusInputRow( - initialEmoji = pickerState.initialEmoji, - initialText = pickerState.initialText, - onConfirm = { emoji, text -> - state.eventSink(UserStatusEvent.Set(UserStatus(emoji, text))) - }, + emoji = pickerState.emoji, + text = pickerState.text, + onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, + onTextChange = { state.eventSink(UserStatusEvent.UpdateCustomText(it)) }, + onConfirm = { state.eventSink(UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.text))) }, onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, modifier = modifier, ) @@ -91,14 +95,9 @@ private fun EmptyStatusRow( ) { ListItem( headlineContent = { - Text( - text = stringResource(R.string.screen_preferences_user_status_placeholder), - color = ElementTheme.colors.textSecondary, - ) - }, - leadingContent = { - Text(text = "πŸ™‚", style = ElementTheme.typography.fontBodyLgRegular) + Text(text = stringResource(R.string.screen_preferences_user_status_placeholder)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), modifier = modifier.clickable(onClick = onClick), ) } @@ -116,15 +115,14 @@ private fun CurrentStatusRow( } ListItem( headlineContent = { Text(text = text) }, - leadingContent = { - Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) - }, - trailingContent = { - TextButton(onClick = onClear) { - Text(text = stringResource(CommonStrings.action_clear)) + leadingContent = ListItemContent.Text(emoji), + trailingContent = ListItemContent.Custom({ + IconButton(onClick = onClear) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) } - }, - modifier = modifier.clickable(onClick = onClick), + }), + onClick = onClick, + modifier = modifier, ) } @@ -134,16 +132,15 @@ private fun UserStatusPickerBottomSheet( onDismiss: () -> Unit, onSelectPredefined: (UserStatus) -> Unit, onSelectCustom: () -> Unit, + modifier: Modifier = Modifier, ) { - ModalBottomSheet(onDismissRequest = onDismiss) { + ModalBottomSheet(onDismissRequest = onDismiss, scrollable = true, modifier = modifier) { PredefinedUserStatus.entries.forEach { predefined -> val label = stringResource(predefined.labelRes) ListItem( headlineContent = { Text(text = label) }, - leadingContent = { - Text(text = predefined.emoji, style = ElementTheme.typography.fontBodyLgRegular) - }, - modifier = Modifier.clickable { + leadingContent = ListItemContent.Text(text = predefined.emoji), + onClick = { onSelectPredefined(UserStatus(emoji = predefined.emoji, text = label)) }, ) @@ -152,25 +149,54 @@ private fun UserStatusPickerBottomSheet( headlineContent = { Text(text = stringResource(R.string.common_user_status_custom)) }, - leadingContent = { - Text(text = "✏️", style = ElementTheme.typography.fontBodyLgRegular) - }, - modifier = Modifier.clickable(onClick = onSelectCustom), + leadingContent = ListItemContent.Text(text = "✏️"), + onClick = onSelectCustom, ) } } @Composable private fun CustomStatusInputRow( - initialEmoji: String, - initialText: String, - onConfirm: (emoji: String, text: String) -> Unit, + emoji: String, + text: String, + onEmojiChange: (String) -> Unit, + onTextChange: (String) -> Unit, + onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, ) { - var emoji by remember { mutableStateOf(initialEmoji) } - var text by remember { mutableStateOf(initialText) } + ListItem( + headlineContent = { + TextField( + value = text, + onValueChange = { if (it.length <= 30) onTextChange(it) }, + placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { if (text.isNotBlank()) onConfirm() } + ), + singleLine = true, + ) + }, + trailingContent = ListItemContent.Custom({ + TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) + }), + leadingContent = ListItemContent.Custom( { + Surface( + shape = CircleShape, + border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary), + modifier = Modifier + .size(50.dp) + .clickable { }, + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) + } + } + }) + ) + /* Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -188,25 +214,21 @@ private fun CustomStatusInputRow( Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) } } - OutlinedTextField( + TextField( value = text, onValueChange = { if (it.length <= 30) text = it }, - placeholder = { - Text(text = stringResource(R.string.screen_preferences_user_status_custom_hint)) - }, + placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { if (text.isNotBlank()) onConfirm(emoji, text) } ), singleLine = true, - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp), + modifier = Modifier.weight(1f).padding(horizontal = 8.dp), ) - TextButton(onClick = onCancel) { - Text(text = stringResource(CommonStrings.action_cancel)) - } + TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) } + + */ } @PreviewsDayNight @@ -239,7 +261,7 @@ internal fun UserStatusRowCustomInputPreview() = ElementPreview { UserStatusRow( state = UserStatusState( displayedStatus = null, - pickerState = UserStatusPickerState.CustomInput(initialEmoji = "πŸ˜€", initialText = ""), + pickerState = UserStatusPickerState.CustomInput(emoji = "πŸ˜€", text = ""), eventSink = {}, ) ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt deleted file mode 100644 index b2be2d75d47..00000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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.di - -import dev.zacsweers.metro.BindingContainer -import dev.zacsweers.metro.Binds -import dev.zacsweers.metro.ContributesTo -import io.element.android.features.preferences.impl.userstatus.UserStatusPresenter -import io.element.android.features.preferences.impl.userstatus.UserStatusState -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.di.SessionScope - -@ContributesTo(SessionScope::class) -@BindingContainer -interface UserStatusModule { - @Binds - fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter -} 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 index d8187462f85..42f1e533232 100644 --- 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 @@ -80,8 +80,8 @@ class UserStatusPresenterTest { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput - assertThat(pickerState.initialEmoji).isEqualTo("πŸ˜€") - assertThat(pickerState.initialText).isEqualTo("") + assertThat(pickerState.emoji).isEqualTo("πŸ˜€") + assertThat(pickerState.text).isEqualTo("") } } @@ -97,8 +97,32 @@ class UserStatusPresenterTest { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput - assertThat(pickerState.initialEmoji).isEqualTo("🌴") - assertThat(pickerState.initialText).isEqualTo("Away") + assertThat(pickerState.emoji).isEqualTo("🌴") + assertThat(pickerState.text).isEqualTo("Away") + } + } + + @Test + fun `UpdateCustomText updates text in CustomInput state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + awaitItem().eventSink(UserStatusEvent.UpdateCustomText("Focusing")) + val state = awaitItem() + assertThat((state.pickerState as UserStatusPickerState.CustomInput).text).isEqualTo("Focusing") + } + } + + @Test + fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("πŸš€")) + val state = awaitItem() + assertThat((state.pickerState as UserStatusPickerState.CustomInput).emoji).isEqualTo("πŸš€") } } From 73a8a3d5d88ceb915f57b0a986a8fd7b67afecfc Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 15 Jun 2026 15:29:50 +0200 Subject: [PATCH 13/30] Use TextFieldState in CustomInput state for proper MVI text handling --- .../impl/userstatus/UserStatusEvent.kt | 2 -- .../impl/userstatus/UserStatusPresenter.kt | 15 +++++--- .../impl/userstatus/UserStatusState.kt | 3 +- .../impl/userstatus/UserStatusView.kt | 34 ++++++++++--------- .../userstatus/UserStatusPresenterTest.kt | 17 ++-------- 5 files changed, 33 insertions(+), 38 deletions(-) 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 index d78be1607bf..80dd02e08ab 100644 --- 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 @@ -22,8 +22,6 @@ sealed interface UserStatusEvent { data object CancelCustomInput : UserStatusEvent /** User changed the emoji in the custom input row. */ data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent - /** User changed the text in the custom input row. */ - data class UpdateCustomText(val text: String) : UserStatusEvent /** User tapped the clear action on an existing status. */ data object Clear : 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 index d765f358008..0bb626b159b 100644 --- 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 @@ -7,6 +7,9 @@ package io.element.android.features.preferences.impl.userstatus +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.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -31,6 +34,7 @@ class UserStatusPresenter( override fun present(): UserStatusState { val userProfile by matrixClient.userProfile.collectAsState() var pickerState by remember { mutableStateOf(UserStatusPickerState.Hidden) } + val customTextFieldState = remember { TextFieldState() } val coroutineScope = rememberCoroutineScope() fun handleEvent(event: UserStatusEvent) { @@ -39,9 +43,14 @@ class UserStatusPresenter( UserStatusEvent.Dismiss -> 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 ?: "πŸ˜€", - text = raw?.text ?: "", + textFieldState = customTextFieldState, ) } is UserStatusEvent.Set -> { @@ -57,10 +66,6 @@ class UserStatusPresenter( val current = pickerState as? UserStatusPickerState.CustomInput ?: return pickerState = current.copy(emoji = event.emoji) } - is UserStatusEvent.UpdateCustomText -> { - val current = pickerState as? UserStatusPickerState.CustomInput ?: return - pickerState = current.copy(text = event.text) - } } } 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 index df1a97a5e3a..b1fe11d1edb 100644 --- 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 @@ -8,6 +8,7 @@ package io.element.android.features.preferences.impl.userstatus import androidx.annotation.StringRes +import androidx.compose.foundation.text.input.TextFieldState import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.DisplayedStatus @@ -22,7 +23,7 @@ sealed interface UserStatusPickerState { data object ShowingPicker : UserStatusPickerState data class CustomInput( val emoji: String = "πŸ˜€", - val text: String = "", + val textFieldState: TextFieldState, ) : UserStatusPickerState } 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 index 956400fa632..b63ae2630e4 100644 --- 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 @@ -15,8 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -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.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -32,6 +34,7 @@ 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.Button +import io.element.android.libraries.designsystem.theme.components.FilledTextField 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 @@ -40,7 +43,6 @@ import io.element.android.libraries.designsystem.theme.components.ModalBottomShe 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.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @@ -77,10 +79,13 @@ fun UserStatusRow( is UserStatusPickerState.CustomInput -> { CustomStatusInputRow( emoji = pickerState.emoji, - text = pickerState.text, + textFieldState = pickerState.textFieldState, onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, - onTextChange = { state.eventSink(UserStatusEvent.UpdateCustomText(it)) }, - onConfirm = { state.eventSink(UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.text))) }, + onConfirm = { + state.eventSink( + UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) + ) + }, onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, modifier = modifier, ) @@ -158,24 +163,21 @@ private fun UserStatusPickerBottomSheet( @Composable private fun CustomStatusInputRow( emoji: String, - text: String, + textFieldState: TextFieldState, onEmojiChange: (String) -> Unit, - onTextChange: (String) -> Unit, onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, ) { ListItem( headlineContent = { - TextField( - value = text, - onValueChange = { if (it.length <= 30) onTextChange(it) }, - placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), + FilledTextField( + state = textFieldState, + placeholder = { Text(stringResource(R.string.screen_preferences_user_status_custom_hint)) }, + inputTransformation = InputTransformation { if (length > 30) revertAllChanges() }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { if (text.isNotBlank()) onConfirm() } - ), - singleLine = true, + keyboardActions = { if (textFieldState.text.isNotBlank()) onConfirm() }, + lineLimits = TextFieldLineLimits.SingleLine, ) }, trailingContent = ListItemContent.Custom({ @@ -261,7 +263,7 @@ internal fun UserStatusRowCustomInputPreview() = ElementPreview { UserStatusRow( state = UserStatusState( displayedStatus = null, - pickerState = UserStatusPickerState.CustomInput(emoji = "πŸ˜€", text = ""), + pickerState = UserStatusPickerState.CustomInput(emoji = "πŸ˜€", textFieldState = TextFieldState()), eventSink = {}, ) ) 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 index 42f1e533232..349361a61b1 100644 --- 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 @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.test.FakeMatrixClient import kotlinx.coroutines.test.runTest import org.junit.Test +import androidx.compose.foundation.text.input.TextFieldState class UserStatusPresenterTest { @@ -81,7 +82,7 @@ class UserStatusPresenterTest { val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput assertThat(pickerState.emoji).isEqualTo("πŸ˜€") - assertThat(pickerState.text).isEqualTo("") + assertThat(pickerState.textFieldState.text.toString()).isEqualTo("") } } @@ -98,19 +99,7 @@ class UserStatusPresenterTest { val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput assertThat(pickerState.emoji).isEqualTo("🌴") - assertThat(pickerState.text).isEqualTo("Away") - } - } - - @Test - fun `UpdateCustomText updates text in CustomInput state`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { - awaitItem().eventSink(UserStatusEvent.OpenCustomInput) - awaitItem().eventSink(UserStatusEvent.UpdateCustomText("Focusing")) - val state = awaitItem() - assertThat((state.pickerState as UserStatusPickerState.CustomInput).text).isEqualTo("Focusing") + assertThat(pickerState.textFieldState.text.toString()).isEqualTo("Away") } } From 57617a362a38fd2d9d023f309f56afeeaabe3668 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 15 Jun 2026 16:05:20 +0200 Subject: [PATCH 14/30] Add TextFieldState overload to design system TextField, use it in UserStatusView --- .../impl/userstatus/UserStatusView.kt | 11 ++-- .../theme/components/TextField.kt | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) 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 index b63ae2630e4..efb06a42def 100644 --- 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 @@ -34,7 +34,7 @@ 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.Button -import io.element.android.libraries.designsystem.theme.components.FilledTextField +import io.element.android.libraries.designsystem.theme.components.TextField 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 @@ -171,12 +171,12 @@ private fun CustomStatusInputRow( ) { ListItem( headlineContent = { - FilledTextField( + TextField( state = textFieldState, - placeholder = { Text(stringResource(R.string.screen_preferences_user_status_custom_hint)) }, + placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), inputTransformation = InputTransformation { if (length > 30) revertAllChanges() }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = { if (textFieldState.text.isNotBlank()) onConfirm() }, + onKeyboardAction = { if (textFieldState.text.isNotBlank()) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, ) }, @@ -195,7 +195,8 @@ private fun CustomStatusInputRow( Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) } } - }) + }), + modifier = modifier, ) /* 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?, From 167c1ced80e30b673597a8d7205f9f1d1af73066 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 15 Jun 2026 17:57:52 +0200 Subject: [PATCH 15/30] Add clear button in custom status text field, toggle Cancel/Save on text presence --- .../impl/userstatus/UserStatusView.kt | 60 +++++++------------ 1 file changed, 23 insertions(+), 37 deletions(-) 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 index efb06a42def..2f3495ec1e3 100644 --- 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 @@ -19,9 +19,14 @@ 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.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -102,6 +107,9 @@ private fun EmptyStatusRow( headlineContent = { Text(text = stringResource(R.string.screen_preferences_user_status_placeholder)) }, + trailingContent = ListItemContent.Custom({ + Box(modifier = Modifier.minimumInteractiveComponentSize()) + }), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), modifier = modifier.clickable(onClick = onClick), ) @@ -169,19 +177,31 @@ private fun CustomStatusInputRow( onCancel: () -> Unit, modifier: Modifier = Modifier, ) { + val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } } ListItem( headlineContent = { TextField( state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), - inputTransformation = InputTransformation { if (length > 30) revertAllChanges() }, + inputTransformation = InputTransformation.maxLength(maxLength = 30), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - onKeyboardAction = { if (textFieldState.text.isNotBlank()) onConfirm() }, + onKeyboardAction = { if (hasText) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, + trailingIcon = if (hasText) { + { + IconButton(onClick = { textFieldState.clearText() }) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + } + } + } else null, ) }, trailingContent = ListItemContent.Custom({ - TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) + if (hasText) { + TextButton(onClick = onConfirm, text = stringResource(CommonStrings.action_save)) + } else { + TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) + } }), leadingContent = ListItemContent.Custom( { Surface( @@ -198,40 +218,6 @@ private fun CustomStatusInputRow( }), modifier = modifier, ) - - /* - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - ) { - Surface( - shape = CircleShape, - color = ElementTheme.colors.bgSubtleSecondary, - modifier = Modifier - .size(40.dp) - .clickable { }, - ) { - Box(contentAlignment = Alignment.Center) { - Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) - } - } - TextField( - value = text, - onValueChange = { if (it.length <= 30) text = it }, - placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { if (text.isNotBlank()) onConfirm(emoji, text) } - ), - singleLine = true, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - ) - TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) - } - - */ } @PreviewsDayNight From 889fd08b5c20cc2d5b9dd6b61332e95b94d76282 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 16 Jun 2026 11:25:08 +0200 Subject: [PATCH 16/30] Fix button width jitter using rememberTextMeasurer to derive stable min-width --- .../impl/userstatus/UserStatusView.kt | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) 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 index 2f3495ec1e3..0c11faaaece 100644 --- 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 @@ -10,10 +10,9 @@ 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.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation @@ -29,8 +28,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons @@ -38,8 +39,6 @@ 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.Button -import io.element.android.libraries.designsystem.theme.components.TextField 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 @@ -48,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.ModalBottomShe 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.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @@ -128,7 +128,9 @@ private fun CurrentStatusRow( } ListItem( headlineContent = { Text(text = text) }, - leadingContent = ListItemContent.Text(emoji), + leadingContent = ListItemContent.Custom({ + Text(text = emoji, modifier = Modifier.size(24.dp)) + }), trailingContent = ListItemContent.Custom({ IconButton(onClick = onClear) { Icon(imageVector = CompoundIcons.Close(), contentDescription = null) @@ -178,32 +180,47 @@ private fun CustomStatusInputRow( modifier: Modifier = Modifier, ) { val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } } + val saveLabel = stringResource(CommonStrings.action_save) + val cancelLabel = stringResource(CommonStrings.action_cancel) + val textStyle = ElementTheme.typography.fontBodyLgMedium + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + // Measure both labels and derive a stable min-width so the button never resizes. + // 24.dp = 12.dp start + 12.dp end padding of TextButton (Large, no icon). + val minButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) { + val saveWidth = textMeasurer.measure(saveLabel, textStyle).size.width + val cancelWidth = textMeasurer.measure(cancelLabel, textStyle).size.width + with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 24.dp + } ListItem( headlineContent = { TextField( + modifier = Modifier.fillMaxWidth(), state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), inputTransformation = InputTransformation.maxLength(maxLength = 30), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), onKeyboardAction = { if (hasText) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, - trailingIcon = if (hasText) { - { - IconButton(onClick = { textFieldState.clearText() }) { - Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + trailingIcon = { + if (hasText) { + Box(modifier = Modifier.clickable { + textFieldState.clearText() + }) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_cancel)) } } - } else null, + } ) }, trailingContent = ListItemContent.Custom({ - if (hasText) { - TextButton(onClick = onConfirm, text = stringResource(CommonStrings.action_save)) - } else { - TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) - } + TextButton( + onClick = if (hasText) onConfirm else onCancel, + text = if (hasText) saveLabel else cancelLabel, + modifier = Modifier.widthIn(min = minButtonWidth), + ) }), - leadingContent = ListItemContent.Custom( { + leadingContent = ListItemContent.Custom({ Surface( shape = CircleShape, border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary), From 078b63fd7cc6b8f85d9c8f00af90912bec73b1c5 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 16 Jun 2026 11:32:47 +0200 Subject: [PATCH 17/30] Move preview cases to UserStatusStateProvider, use @PreviewParameter in view --- .../userstatus/UserStatusStateProvider.kt | 14 ++++++++ .../impl/userstatus/UserStatusView.kt | 35 ++----------------- 2 files changed, 17 insertions(+), 32 deletions(-) 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 index 65ec264cc4c..7a7a1d4dac1 100644 --- 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 @@ -7,7 +7,21 @@ 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(pickerState = UserStatusPickerState.CustomInput(emoji = "πŸ˜€", textFieldState = TextFieldState())), + aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "πŸš€", textFieldState = TextFieldState("Working on something"))), + ) +} fun aUserStatusState( displayedStatus: DisplayedStatus? = null, 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 index 0c11faaaece..3c30701834e 100644 --- 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 @@ -37,6 +37,7 @@ 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 androidx.compose.ui.tooling.preview.PreviewParameter 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 @@ -239,36 +240,6 @@ private fun CustomStatusInputRow( @PreviewsDayNight @Composable -internal fun UserStatusRowEmptyPreview() = ElementPreview { - UserStatusRow( - state = UserStatusState( - displayedStatus = null, - pickerState = UserStatusPickerState.Hidden, - eventSink = {}, - ) - ) -} - -@PreviewsDayNight -@Composable -internal fun UserStatusRowSetPreview() = ElementPreview { - UserStatusRow( - state = UserStatusState( - displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")), - pickerState = UserStatusPickerState.Hidden, - eventSink = {}, - ) - ) -} - -@PreviewsDayNight -@Composable -internal fun UserStatusRowCustomInputPreview() = ElementPreview { - UserStatusRow( - state = UserStatusState( - displayedStatus = null, - pickerState = UserStatusPickerState.CustomInput(emoji = "πŸ˜€", textFieldState = TextFieldState()), - eventSink = {}, - ) - ) +internal fun UserStatusRowPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview { + UserStatusRow(state = state) } From 2a0e41652af273fa7a0d435a943ae7ccdbf39c13 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 16 Jun 2026 12:13:58 +0200 Subject: [PATCH 18/30] Match row heights: add vertical padding to plain text rows to align with TextField row --- .../impl/userstatus/UserStatusView.kt | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) 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 index 3c30701834e..035861f2438 100644 --- 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 @@ -10,8 +10,9 @@ 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.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.visible import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions @@ -32,12 +33,12 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.rememberTextMeasurer +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 androidx.compose.ui.tooling.preview.PreviewParameter 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 @@ -106,7 +107,10 @@ private fun EmptyStatusRow( ) { ListItem( headlineContent = { - Text(text = stringResource(R.string.screen_preferences_user_status_placeholder)) + Text( + text = stringResource(R.string.screen_preferences_user_status_placeholder), + modifier = Modifier.padding(vertical = 16.dp), + ) }, trailingContent = ListItemContent.Custom({ Box(modifier = Modifier.minimumInteractiveComponentSize()) @@ -128,7 +132,7 @@ private fun CurrentStatusRow( is DisplayedStatus.InCall -> "🎧" to stringResource(R.string.common_user_status_on_a_call) } ListItem( - headlineContent = { Text(text = text) }, + headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, leadingContent = ListItemContent.Custom({ Text(text = emoji, modifier = Modifier.size(24.dp)) }), @@ -187,16 +191,15 @@ private fun CustomStatusInputRow( val density = LocalDensity.current val textMeasurer = rememberTextMeasurer() // Measure both labels and derive a stable min-width so the button never resizes. - // 24.dp = 12.dp start + 12.dp end padding of TextButton (Large, no icon). - val minButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) { + // 32.dp = 16.dp start + 16.dp end padding of TextButton (Large, no icon). + val actionButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) { val saveWidth = textMeasurer.measure(saveLabel, textStyle).size.width val cancelWidth = textMeasurer.measure(cancelLabel, textStyle).size.width - with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 24.dp + with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 32.dp } ListItem( headlineContent = { TextField( - modifier = Modifier.fillMaxWidth(), state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), inputTransformation = InputTransformation.maxLength(maxLength = 30), @@ -204,12 +207,14 @@ private fun CustomStatusInputRow( onKeyboardAction = { if (hasText) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, trailingIcon = { - if (hasText) { - Box(modifier = Modifier.clickable { - textFieldState.clearText() - }) { - Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_cancel)) - } + Box(modifier = Modifier.clickable(enabled = hasText) { + textFieldState.clearText() + }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_cancel), + modifier = Modifier.visible(hasText) + ) } } ) @@ -218,7 +223,7 @@ private fun CustomStatusInputRow( TextButton( onClick = if (hasText) onConfirm else onCancel, text = if (hasText) saveLabel else cancelLabel, - modifier = Modifier.widthIn(min = minButtonWidth), + modifier = Modifier.widthIn(min = actionButtonWidth), ) }), leadingContent = ListItemContent.Custom({ From 553a4785e182739c7e629f9e434a8a0375ecd83c Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 11:05:53 +0200 Subject: [PATCH 19/30] Rename Event --- .../impl/userstatus/UserStatusEvent.kt | 15 ++++----------- .../impl/userstatus/UserStatusPresenter.kt | 14 ++++++-------- .../impl/userstatus/UserStatusState.kt | 2 +- .../preferences/impl/userstatus/UserStatusView.kt | 12 ++++++------ .../impl/userstatus/UserStatusPresenterTest.kt | 15 +++++++-------- 5 files changed, 24 insertions(+), 34 deletions(-) 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 index 80dd02e08ab..815cd12d66d 100644 --- 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 @@ -10,18 +10,11 @@ package io.element.android.features.preferences.impl.userstatus import io.element.android.libraries.matrix.api.user.UserStatus sealed interface UserStatusEvent { - /** User tapped the status row β€” open the picker bottom sheet. */ - data object Open : UserStatusEvent - /** User dismissed the bottom sheet without selecting. */ - data object Dismiss : UserStatusEvent - /** User selected a predefined or confirmed a custom status. */ - data class Set(val status: UserStatus) : UserStatusEvent - /** User tapped "Custom…" in the bottom sheet. */ + data object OpenPicker : UserStatusEvent + data object DismissPicker : UserStatusEvent + data class SetStatus(val status: UserStatus) : UserStatusEvent data object OpenCustomInput : UserStatusEvent - /** User tapped Cancel in the inline custom input row. */ data object CancelCustomInput : UserStatusEvent - /** User changed the emoji in the custom input row. */ data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent - /** User tapped the clear action on an existing status. */ - data object Clear : 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 index 0bb626b159b..384e09862a7 100644 --- 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 @@ -7,8 +7,8 @@ package io.element.android.features.preferences.impl.userstatus -import androidx.compose.foundation.text.input.TextFieldState 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 @@ -18,11 +18,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.user.UserStatus import kotlinx.coroutines.launch @ContributesBinding(SessionScope::class) @@ -34,13 +32,13 @@ class UserStatusPresenter( override fun present(): UserStatusState { val userProfile by matrixClient.userProfile.collectAsState() var pickerState by remember { mutableStateOf(UserStatusPickerState.Hidden) } - val customTextFieldState = remember { TextFieldState() } + val customTextFieldState = rememberTextFieldState() val coroutineScope = rememberCoroutineScope() fun handleEvent(event: UserStatusEvent) { when (event) { - UserStatusEvent.Open -> pickerState = UserStatusPickerState.ShowingPicker - UserStatusEvent.Dismiss -> pickerState = UserStatusPickerState.Hidden + UserStatusEvent.OpenPicker -> pickerState = UserStatusPickerState.ShowingPicker + UserStatusEvent.DismissPicker -> pickerState = UserStatusPickerState.Hidden UserStatusEvent.OpenCustomInput -> { val raw = userProfile.rawStatus if (raw != null) { @@ -53,11 +51,11 @@ class UserStatusPresenter( textFieldState = customTextFieldState, ) } - is UserStatusEvent.Set -> { + is UserStatusEvent.SetStatus -> { pickerState = UserStatusPickerState.Hidden coroutineScope.launch { matrixClient.setUserStatus(event.status) } } - UserStatusEvent.Clear -> { + UserStatusEvent.ClearStatus -> { pickerState = UserStatusPickerState.Hidden coroutineScope.launch { matrixClient.clearUserStatus() } } 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 index b1fe11d1edb..f79ae86d8ba 100644 --- 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 @@ -22,7 +22,7 @@ sealed interface UserStatusPickerState { data object Hidden : UserStatusPickerState data object ShowingPicker : UserStatusPickerState data class CustomInput( - val emoji: String = "πŸ˜€", + val emoji: String, val textFieldState: TextFieldState, ) : UserStatusPickerState } 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 index 035861f2438..e211fb1110e 100644 --- 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 @@ -65,20 +65,20 @@ fun UserStatusRow( if (displayedStatus != null) { CurrentStatusRow( displayedStatus = displayedStatus, - onClick = { state.eventSink(UserStatusEvent.Open) }, - onClear = { state.eventSink(UserStatusEvent.Clear) }, + onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, + onClear = { state.eventSink(UserStatusEvent.ClearStatus) }, modifier = modifier, ) } else { EmptyStatusRow( - onClick = { state.eventSink(UserStatusEvent.Open) }, + onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, modifier = modifier, ) } if (pickerState == UserStatusPickerState.ShowingPicker) { UserStatusPickerBottomSheet( - onDismiss = { state.eventSink(UserStatusEvent.Dismiss) }, - onSelectPredefined = { status -> state.eventSink(UserStatusEvent.Set(status)) }, + onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, + onSelectPredefined = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, ) } @@ -90,7 +90,7 @@ fun UserStatusRow( onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, onConfirm = { state.eventSink( - UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) + UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) ) }, onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, 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 index 349361a61b1..455aefe1808 100644 --- 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 @@ -16,7 +16,6 @@ import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.test.FakeMatrixClient import kotlinx.coroutines.test.runTest import org.junit.Test -import androidx.compose.foundation.text.input.TextFieldState class UserStatusPresenterTest { @@ -40,7 +39,7 @@ class UserStatusPresenterTest { createPresenter().present() }.test { val state = awaitItem() - state.eventSink(UserStatusEvent.Open) + state.eventSink(UserStatusEvent.OpenPicker) assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker) } } @@ -51,9 +50,9 @@ class UserStatusPresenterTest { createPresenter().present() }.test { val state = awaitItem() - state.eventSink(UserStatusEvent.Open) + state.eventSink(UserStatusEvent.OpenPicker) awaitItem() // ShowingPicker - state.eventSink(UserStatusEvent.Dismiss) + state.eventSink(UserStatusEvent.DismissPicker) assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden) } } @@ -65,7 +64,7 @@ class UserStatusPresenterTest { createPresenter(client).present() }.test { val status = UserStatus(emoji = "πŸ’¬", text = "In a meeting") - awaitItem().eventSink(UserStatusEvent.Set(status)) + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) val newState = awaitItem() assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status)) @@ -93,7 +92,7 @@ class UserStatusPresenterTest { createPresenter(client).present() }.test { val status = UserStatus(emoji = "🌴", text = "Away") - awaitItem().eventSink(UserStatusEvent.Set(status)) + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() @@ -133,9 +132,9 @@ class UserStatusPresenterTest { createPresenter(client).present() }.test { val status = UserStatus(emoji = "β˜•", text = "Be right back") - awaitItem().eventSink(UserStatusEvent.Set(status)) + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition - awaitItem().eventSink(UserStatusEvent.Clear) + awaitItem().eventSink(UserStatusEvent.ClearStatus) val clearedState = awaitItem() assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(clearedState.displayedStatus).isNull() From 1cfd3e4dfb64f2d479098b70f8d06f342a62cb55 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 15:55:49 +0200 Subject: [PATCH 20/30] Improve UI/UX for setting status --- .../impl/userstatus/UserStatusPresenter.kt | 1 + .../impl/userstatus/UserStatusState.kt | 2 + .../userstatus/UserStatusStateProvider.kt | 7 ++ .../impl/userstatus/UserStatusView.kt | 109 ++++++++++++------ .../impl/root/PreferencesRootViewTest.kt | 9 +- 5 files changed, 91 insertions(+), 37 deletions(-) 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 index 384e09862a7..e5c22fd568c 100644 --- 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 @@ -69,6 +69,7 @@ class UserStatusPresenter( 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 index f79ae86d8ba..7cfa03fa81f 100644 --- 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 @@ -11,9 +11,11 @@ import androidx.annotation.StringRes import androidx.compose.foundation.text.input.TextFieldState 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, ) 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 index 7a7a1d4dac1..087cb85355b 100644 --- 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 @@ -18,6 +18,11 @@ internal class UserStatusStateProvider : PreviewParameterProvider 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 index e211fb1110e..1b0b8a478dd 100644 --- 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 @@ -12,8 +12,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.visible -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation @@ -23,16 +21,22 @@ import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.maxLength import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -46,6 +50,7 @@ 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.hide 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 @@ -77,6 +82,7 @@ fun UserStatusRow( } if (pickerState == UserStatusPickerState.ShowingPicker) { UserStatusPickerBottomSheet( + currentRawStatus = state.rawStatus, onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, onSelectPredefined = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, @@ -87,6 +93,7 @@ fun UserStatusRow( CustomStatusInputRow( emoji = pickerState.emoji, textFieldState = pickerState.textFieldState, + rawStatus = state.rawStatus, onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, onConfirm = { state.eventSink( @@ -134,7 +141,7 @@ private fun CurrentStatusRow( ListItem( headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, leadingContent = ListItemContent.Custom({ - Text(text = emoji, modifier = Modifier.size(24.dp)) + Text(text = emoji, modifier = Modifier.size(24.dp), textAlign = TextAlign.Center) }), trailingContent = ListItemContent.Custom({ IconButton(onClick = onClear) { @@ -149,19 +156,38 @@ private fun CurrentStatusRow( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun UserStatusPickerBottomSheet( + currentRawStatus: UserStatus?, onDismiss: () -> Unit, onSelectPredefined: (UserStatus) -> Unit, onSelectCustom: () -> Unit, modifier: Modifier = Modifier, ) { - ModalBottomSheet(onDismissRequest = onDismiss, scrollable = true, 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.Text(text = predefined.emoji), + trailingContent = if (isSelected) ListItemContent.Custom({ + Icon( + imageVector = CompoundIcons.Check(), + contentDescription = null, + tint = ElementTheme.colors.iconAccentPrimary, + ) + }) else null, onClick = { - onSelectPredefined(UserStatus(emoji = predefined.emoji, text = label)) + sheetState.hide(coroutineScope) { + onSelectPredefined(predefinedUserStatus) + } }, ) } @@ -170,7 +196,11 @@ private fun UserStatusPickerBottomSheet( Text(text = stringResource(R.string.common_user_status_custom)) }, leadingContent = ListItemContent.Text(text = "✏️"), - onClick = onSelectCustom, + onClick = { + sheetState.hide(coroutineScope) { + onSelectCustom() + } + }, ) } } @@ -179,52 +209,63 @@ private fun UserStatusPickerBottomSheet( private fun CustomStatusInputRow( emoji: String, textFieldState: TextFieldState, + rawStatus: UserStatus?, onEmojiChange: (String) -> Unit, 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 textStyle = ElementTheme.typography.fontBodyLgMedium - val density = LocalDensity.current - val textMeasurer = rememberTextMeasurer() - // Measure both labels and derive a stable min-width so the button never resizes. - // 32.dp = 16.dp start + 16.dp end padding of TextButton (Large, no icon). - val actionButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) { - val saveWidth = textMeasurer.measure(saveLabel, textStyle).size.width - val cancelWidth = textMeasurer.measure(cancelLabel, textStyle).size.width - with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 32.dp - } + 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(maxLength = 30), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - onKeyboardAction = { if (hasText) onConfirm() }, + onKeyboardAction = { if (hasChanges) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, - trailingIcon = { - Box(modifier = Modifier.clickable(enabled = hasText) { - textFieldState.clearText() - }) { - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_cancel), - modifier = Modifier.visible(hasText) - ) + 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({ - TextButton( - onClick = if (hasText) onConfirm else onCancel, - text = if (hasText) saveLabel else cancelLabel, - modifier = Modifier.widthIn(min = actionButtonWidth), - ) + // Layout measures both buttons to determine the max natural width, + // then places only the active one β€” no hardcoded padding values needed. + 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) { + if (hasChanges) placeables[0].placeRelative(0, 0) + else placeables[1].placeRelative(0, 0) + } + } }), leadingContent = ListItemContent.Custom({ Surface( 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 5c9d81875e9..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 @@ -202,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() } } @@ -229,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() } } @@ -393,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() } } From dd24e14cf61f6f64fa4b139f2b7dad3519acb819 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 17:20:58 +0200 Subject: [PATCH 21/30] Gate user status behind UserStatus feature flag --- .../impl/root/PreferencesRootPresenter.kt | 4 +- .../impl/root/PreferencesRootState.kt | 2 +- .../impl/root/PreferencesRootStateProvider.kt | 2 +- .../impl/root/PreferencesRootView.kt | 39 ++++++++++++++----- .../libraries/featureflag/api/FeatureFlags.kt | 7 ++++ 5 files changed, 40 insertions(+), 14 deletions(-) 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 fa69382cf8a..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 @@ -62,20 +62,20 @@ class PreferencesRootPresenter( ) : Presenter { @Composable override fun present(): PreferencesRootState { - val userStatusState = userStatusPresenter.present() val coroutineScope = rememberCoroutineScope() val matrixUser = matrixClient.userProfile.collectAsState() LaunchedEffect(Unit) { // 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 -> 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 8163e4b7a0e..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 @@ -17,7 +17,7 @@ import kotlinx.collections.immutable.ImmutableList data class PreferencesRootState( val myUser: MatrixUser, - val userStatusState: UserStatusState, + 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 9e1557fd7b4..1ac2e1f6f5f 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 @@ -88,7 +88,7 @@ fun aPreferencesRootState( nbOfBlockedUsers: Int = 0, showLabsItem: Boolean = false, directLogoutState: DirectLogoutState = aDirectLogoutState(), - userStatusState: UserStatusState = aUserStatusState(), + userStatusState: UserStatusState? = aUserStatusState(), snackbarMessage: SnackbarMessage? = null, eventSink: (PreferencesRootEvent) -> Unit = {}, ) = PreferencesRootState( 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 d92c1e4613a..5221c82dda4 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 @@ -25,6 +25,7 @@ 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.UserStatusRow +import io.element.android.features.preferences.impl.userstatus.UserStatusState 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 @@ -82,15 +83,14 @@ fun PreferencesRootView( }, matrixUser = state.myUser, ) - HorizontalDivider() - UserStatusRow( - state = state.userStatusState, - modifier = Modifier.fillMaxWidth(), - ) - HorizontalDivider() + + if (state.userStatusState != null) { + UserStatusSection(state.userStatusState) + } if (state.isMultiAccountEnabled) { MultiAccountSection( state = state, + showTopDivider = state.userStatusState == null, onAddAccountClick = onAddAccountClick, ) } @@ -135,14 +135,33 @@ fun PreferencesRootView( } @Composable -private fun ColumnScope.MultiAccountSection( - state: PreferencesRootState, - onAddAccountClick: () -> Unit, -) { +private fun ColumnScope.UserStatusSection(userStatusState: UserStatusState) { HorizontalDivider( thickness = 8.dp, color = ElementTheme.colors.bgSubtleSecondary, ) + UserStatusRow( + state = userStatusState, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) +} + +@Composable +private fun ColumnScope.MultiAccountSection( + state: PreferencesRootState, + showTopDivider: Boolean, + onAddAccountClick: () -> Unit, +) { + if(showTopDivider) { + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + } state.otherSessions.forEach { matrixUser -> MatrixUserRow( modifier = Modifier 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 0127e41c995..b298cd2f163 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 @@ -137,4 +137,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, + ), } From d29dad40f944baccff300caf725c3a7496d41385 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 17:37:21 +0200 Subject: [PATCH 22/30] Extract custom status text max length into a constant --- .../features/preferences/impl/userstatus/UserStatusView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 1b0b8a478dd..a39c72f0d75 100644 --- 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 @@ -205,6 +205,8 @@ private fun UserStatusPickerBottomSheet( } } +private const val CUSTOM_STATUS_TEXT_MAX_LENGTH = 30 + @Composable private fun CustomStatusInputRow( emoji: String, @@ -232,7 +234,7 @@ private fun CustomStatusInputRow( modifier = Modifier.focusRequester(focusRequester), state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), - inputTransformation = InputTransformation.maxLength(maxLength = 30), + inputTransformation = InputTransformation.maxLength(CUSTOM_STATUS_TEXT_MAX_LENGTH), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), onKeyboardAction = { if (hasChanges) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, From 061fd82c1ad136be8542f19a8109f28c6eca37be Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 17:37:39 +0200 Subject: [PATCH 23/30] Clip avatar to a circle on the user status screen --- .../features/preferences/impl/userstatus/UserStatusView.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index a39c72f0d75..7856be468a1 100644 --- 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 @@ -32,6 +32,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -275,6 +276,7 @@ private fun CustomStatusInputRow( border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary), modifier = Modifier .size(50.dp) + .clip(CircleShape) .clickable { }, ) { Box(contentAlignment = Alignment.Center) { From be872e6becb360141d4c36c0c86031ba3fd3926d Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 18 Jun 2026 16:42:22 +0200 Subject: [PATCH 24/30] Move user status section below multi-account in preferences --- .../impl/root/PreferencesRootStateProvider.kt | 6 +++- .../impl/root/PreferencesRootView.kt | 36 +++++++++---------- 2 files changed, 23 insertions(+), 19 deletions(-) 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 1ac2e1f6f5f..8e931d37be9 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,11 +11,14 @@ 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.PredefinedUserStatus +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 +import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.ui.strings.CommonStrings @@ -66,8 +69,9 @@ open class PreferencesRootStateProvider : PreviewParameterProvider Unit, ) { - if(showTopDivider) { - HorizontalDivider( - thickness = 8.dp, - color = ElementTheme.colors.bgSubtleSecondary, - ) - } + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) state.otherSessions.forEach { matrixUser -> MatrixUserRow( modifier = Modifier From 4a143c10537a3c7b1e9b2e8193179371b1b16297 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 18 Jun 2026 20:22:09 +0200 Subject: [PATCH 25/30] Format code --- .../impl/root/PreferencesRootStateProvider.kt | 2 -- .../impl/root/PreferencesRootView.kt | 2 +- .../impl/userstatus/UserStatusPresenter.kt | 1 - .../impl/userstatus/UserStatusView.kt | 29 ++++++++++++------- .../userstatus/UserStatusPresenterTest.kt | 1 - .../libraries/matrix/api/MatrixClient.kt | 1 + 6 files changed, 21 insertions(+), 15 deletions(-) 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 8e931d37be9..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,14 +11,12 @@ 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.PredefinedUserStatus 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 -import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.ui.strings.CommonStrings 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 5baf01d9602..324f0a97734 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 @@ -137,7 +137,7 @@ private fun ColumnScope.UserStatusSection( userStatusState: UserStatusState, showTopDivider: Boolean, ) { - if(showTopDivider) { + if (showTopDivider) { HorizontalDivider( thickness = 8.dp, color = ElementTheme.colors.bgSubtleSecondary, 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 index e5c22fd568c..fac82d45050 100644 --- 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 @@ -27,7 +27,6 @@ import kotlinx.coroutines.launch class UserStatusPresenter( private val matrixClient: MatrixClient, ) : Presenter { - @Composable override fun present(): UserStatusState { val userProfile by matrixClient.userProfile.collectAsState() 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 index 7856be468a1..f7d37f0adf2 100644 --- 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 @@ -23,22 +23,22 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.minimumInteractiveComponentSize 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.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester 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.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons @@ -51,11 +51,11 @@ 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.hide 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 @@ -178,13 +178,17 @@ private fun UserStatusPickerBottomSheet( ListItem( headlineContent = { Text(text = label) }, leadingContent = ListItemContent.Text(text = predefined.emoji), - trailingContent = if (isSelected) ListItemContent.Custom({ + trailingContent = if (isSelected) { + ListItemContent.Custom({ Icon( imageVector = CompoundIcons.Check(), contentDescription = null, tint = ElementTheme.colors.iconAccentPrimary, ) - }) else null, + }) + } else { + null + }, onClick = { sheetState.hide(coroutineScope) { onSelectPredefined(predefinedUserStatus) @@ -249,7 +253,9 @@ private fun CustomStatusInputRow( ) } } - } else null + } else { + null + } ) }, trailingContent = ListItemContent.Custom({ @@ -265,8 +271,11 @@ private fun CustomStatusInputRow( val maxWidth = placeables.maxOf { it.width } val maxHeight = placeables.maxOf { it.height } layout(maxWidth, maxHeight) { - if (hasChanges) placeables[0].placeRelative(0, 0) - else placeables[1].placeRelative(0, 0) + if (hasChanges) { + placeables[0].placeRelative(0, 0) + } else { + placeables[1].placeRelative(0, 0) + } } } }), 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 index 455aefe1808..4a03a226078 100644 --- 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 @@ -18,7 +18,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Test class UserStatusPresenterTest { - private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) = UserStatusPresenter(matrixClient = client) 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 001bac0d30b..e7d010d5100 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 @@ -86,6 +86,7 @@ interface MatrixClient { 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 From 29b8068d4a335f2dd5c73a693d8010b1ae0df727 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 19 Jun 2026 15:14:37 +0200 Subject: [PATCH 26/30] Address quality issues on user status --- .../impl/root/PreferencesRootView.kt | 4 ++-- .../userstatus/UserStatusBindingContainer.kt | 21 +++++++++++++++++++ .../impl/userstatus/UserStatusPresenter.kt | 5 ++--- .../impl/userstatus/UserStatusState.kt | 4 +++- .../impl/userstatus/UserStatusView.kt | 20 ++++++++---------- .../matrix/api/user/DisplayedStatus.kt | 2 ++ .../libraries/matrix/impl/RustMatrixClient.kt | 1 + 7 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusBindingContainer.kt 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 324f0a97734..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 @@ -24,8 +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.UserStatusRow 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 @@ -143,7 +143,7 @@ private fun ColumnScope.UserStatusSection( color = ElementTheme.colors.bgSubtleSecondary, ) } - UserStatusRow( + UserStatusView( state = userStatusState, modifier = Modifier.fillMaxWidth(), ) 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/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index fac82d45050..ea09f59706c 100644 --- 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 @@ -17,13 +17,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import kotlinx.coroutines.launch -@ContributesBinding(SessionScope::class) +@Inject class UserStatusPresenter( private val matrixClient: MatrixClient, ) : Presenter { 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 index 7cfa03fa81f..fa61089144f 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -20,6 +21,7 @@ data class UserStatusState( val eventSink: (UserStatusEvent) -> Unit, ) +@Immutable sealed interface UserStatusPickerState { data object Hidden : UserStatusPickerState data object ShowingPicker : UserStatusPickerState @@ -29,7 +31,7 @@ sealed interface UserStatusPickerState { ) : UserStatusPickerState } -enum class PredefinedUserStatus(val emoji: String, @param:StringRes val labelRes: Int) { +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), 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 index f7d37f0adf2..245052120ce 100644 --- 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 @@ -61,7 +61,7 @@ import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @Composable -fun UserStatusRow( +fun UserStatusView( state: UserStatusState, modifier: Modifier = Modifier, ) { @@ -85,8 +85,8 @@ fun UserStatusRow( UserStatusPickerBottomSheet( currentRawStatus = state.rawStatus, onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, - onSelectPredefined = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, - onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, + onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, + onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) }, ) } } @@ -95,7 +95,6 @@ fun UserStatusRow( emoji = pickerState.emoji, textFieldState = pickerState.textFieldState, rawStatus = state.rawStatus, - onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, onConfirm = { state.eventSink( UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) @@ -159,8 +158,8 @@ private fun CurrentStatusRow( private fun UserStatusPickerBottomSheet( currentRawStatus: UserStatus?, onDismiss: () -> Unit, - onSelectPredefined: (UserStatus) -> Unit, - onSelectCustom: () -> Unit, + onSelectPredefinedStatus: (UserStatus) -> Unit, + onSelectCustomStatus: () -> Unit, modifier: Modifier = Modifier, ) { val sheetState = rememberModalBottomSheetState() @@ -191,7 +190,7 @@ private fun UserStatusPickerBottomSheet( }, onClick = { sheetState.hide(coroutineScope) { - onSelectPredefined(predefinedUserStatus) + onSelectPredefinedStatus(predefinedUserStatus) } }, ) @@ -203,7 +202,7 @@ private fun UserStatusPickerBottomSheet( leadingContent = ListItemContent.Text(text = "✏️"), onClick = { sheetState.hide(coroutineScope) { - onSelectCustom() + onSelectCustomStatus() } }, ) @@ -217,7 +216,6 @@ private fun CustomStatusInputRow( emoji: String, textFieldState: TextFieldState, rawStatus: UserStatus?, - onEmojiChange: (String) -> Unit, onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, @@ -299,6 +297,6 @@ private fun CustomStatusInputRow( @PreviewsDayNight @Composable -internal fun UserStatusRowPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview { - UserStatusRow(state = state) +internal fun UserStatusViewPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview { + UserStatusView(state = state) } 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 index 046191d7cf5..581658ff28c 100644 --- 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 @@ -8,8 +8,10 @@ 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 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 92107f3ac29..4d40fa21234 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 @@ -138,6 +138,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, From f9a8acf4a55c1bd7f467b1a3f2dc438a609b2498 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 25 Jun 2026 10:50:51 +0000 Subject: [PATCH 27/30] Update screenshots --- ...res.preferences.impl.root_PreferencesRootViewDark_0_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_1_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_2_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_3_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_4_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_5_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_6_en.png | 3 +++ ...es.preferences.impl.root_PreferencesRootViewLight_0_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_1_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_2_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_3_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_4_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_5_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_6_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_0_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_1_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_2_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_3_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_4_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_5_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_6_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_0_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_1_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_2_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_3_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_4_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_5_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_6_en.png | 3 +++ 28 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png 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..423bca1a331 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:cfa5e8a6baef9873454a43857cfa7983ef64dc65a0ff8649ccc96ac1dac7f999 +size 32775 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..83b748ebff2 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:0b82b065f1ea1dda8a159b5688c803ba2f7c7e1d60a31e51a9353a8ec1928321 +size 34166 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..33a7b53d6d4 --- /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:82cf75a0887b9482cd80e04b3c60b588930a0076571b4f5f7ea8247dc525a912 +size 7114 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..58c1aa1c274 --- /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:9c1b2766c7c6c3a27484944d772da87cd2b7acc42f25fd21f19bae6dffea204c +size 7232 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..7f8cabb58bc --- /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:c04a8ab290295a792e3b78d7155be66618aa3d054eb1009431f3d7ae9746f053 +size 28895 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..6d8d5388c48 --- /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:6de59d47eeefb8c03c9ed0694c67b1666eb35cb7f178c57a52041a19b0398fde +size 27426 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..f96db19f31c --- /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:232de960e0b5aa52d824dabdd33b544847d940054b1f90197d48cb3160a7d818 +size 12079 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..0fd9e3adb47 --- /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:5b44a0abf28f2b88bfc8d57e4b3c7ab248840f41190a389ac3c7b31e58202235 +size 11995 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..687070419e6 --- /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:e4311673b25a516c05ca019b72604deb2115a36195d987c116b47f94d9220d82 +size 6736 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..ea9751bafce --- /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:d8e4c37ef03cdf83319be9ad61a8ff6c91a6a33404680e66d81736563c2a2ec9 +size 6872 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..0b5876ab7ef --- /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:5ec521e4dcc594b59eb159aee39141f1dd94ba10fcacfaa33159c4280b8e22ea +size 27739 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..696fea1cf90 --- /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:168aef1da5c35f252d49f77866608a010c0ce42c1c1f6daa2ca0f115a5b5e3cf +size 26228 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..15ea618fb71 --- /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:260a44e1132a30150bd94b4232750b768cd1e74b1b149dc1e2d2261061ebf08d +size 11721 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..0dbf6deb0d3 --- /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:27297084473b61e5dc49ab399785433e73caaada5c29684d346e982a2476077d +size 11428 From bfe21698f21e3a927478861f31c497ae3fd4f819 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 2 Jul 2026 18:04:43 +0200 Subject: [PATCH 28/30] Tweak some UserStatusView ui --- .../impl/userstatus/UserStatusView.kt | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) 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 index 245052120ce..dfc68ff4460 100644 --- 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 @@ -10,8 +10,10 @@ 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 @@ -34,11 +36,11 @@ 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.Constraints import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons @@ -140,12 +142,17 @@ private fun CurrentStatusRow( } ListItem( headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, - leadingContent = ListItemContent.Custom({ - Text(text = emoji, modifier = Modifier.size(24.dp), textAlign = TextAlign.Center) - }), + leadingContent = ListItemContent.Custom { EmojiText(emoji) }, trailingContent = ListItemContent.Custom({ - IconButton(onClick = onClear) { - Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + 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, @@ -153,6 +160,18 @@ private fun CurrentStatusRow( ) } +@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( @@ -176,15 +195,9 @@ private fun UserStatusPickerBottomSheet( val isSelected = currentRawStatus == predefinedUserStatus ListItem( headlineContent = { Text(text = label) }, - leadingContent = ListItemContent.Text(text = predefined.emoji), + leadingContent = ListItemContent.Custom { EmojiText(predefined.emoji) }, trailingContent = if (isSelected) { - ListItemContent.Custom({ - Icon( - imageVector = CompoundIcons.Check(), - contentDescription = null, - tint = ElementTheme.colors.iconAccentPrimary, - ) - }) + ListItemContent.Icon(IconSource.Vector(CompoundIcons.Check()), tintColor = ElementTheme.colors.iconAccentPrimary) } else { null }, @@ -199,7 +212,7 @@ private fun UserStatusPickerBottomSheet( headlineContent = { Text(text = stringResource(R.string.common_user_status_custom)) }, - leadingContent = ListItemContent.Text(text = "✏️"), + leadingContent = ListItemContent.Custom { EmojiText("✏️") }, onClick = { sheetState.hide(coroutineScope) { onSelectCustomStatus() @@ -258,22 +271,22 @@ private fun CustomStatusInputRow( }, trailingContent = ListItemContent.Custom({ // Layout measures both buttons to determine the max natural width, - // then places only the active one β€” no hardcoded padding values needed. + // 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 placeables = measurables.map { it.measure(constraints) } val maxWidth = placeables.maxOf { it.width } val maxHeight = placeables.maxOf { it.height } layout(maxWidth, maxHeight) { - if (hasChanges) { - placeables[0].placeRelative(0, 0) - } else { - placeables[1].placeRelative(0, 0) - } + val active = if (hasChanges) placeables[0] else placeables[1] + active.placeRelative( + x = (maxWidth - active.width) / 2, + y = (maxHeight - active.height) / 2, + ) } } }), @@ -287,7 +300,7 @@ private fun CustomStatusInputRow( .clickable { }, ) { Box(contentAlignment = Alignment.Center) { - Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) + EmojiText(emoji) } } }), From 301f930bcc55e053c7a208bd347db32eb0ea3bb4 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 2 Jul 2026 17:55:54 +0000 Subject: [PATCH 29/30] Update screenshots --- ...res.preferences.impl.root_PreferencesRootViewDark_5_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_5_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_1_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_2_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_3_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_4_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_5_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_6_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_1_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_2_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_3_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_4_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_5_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_6_en.png | 4 ++-- 14 files changed, 28 insertions(+), 28 deletions(-) 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 423bca1a331..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:cfa5e8a6baef9873454a43857cfa7983ef64dc65a0ff8649ccc96ac1dac7f999 -size 32775 +oid sha256:7c75ad887209b88e700c6ad53090ed4c2d7e5a3f49836608999e9a3124a63fc0 +size 32869 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 83b748ebff2..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:0b82b065f1ea1dda8a159b5688c803ba2f7c7e1d60a31e51a9353a8ec1928321 -size 34166 +oid sha256:f12abebdcc42404c0a5719c490f5da4d3d5a1986d469884773bf9ed8071dd27b +size 34154 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 index 33a7b53d6d4..0607484ce06 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82cf75a0887b9482cd80e04b3c60b588930a0076571b4f5f7ea8247dc525a912 -size 7114 +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 index 58c1aa1c274..9f77167b6e2 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c1b2766c7c6c3a27484944d772da87cd2b7acc42f25fd21f19bae6dffea204c -size 7232 +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 index 7f8cabb58bc..470324716e4 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c04a8ab290295a792e3b78d7155be66618aa3d054eb1009431f3d7ae9746f053 -size 28895 +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 index 6d8d5388c48..56c7646b1b7 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6de59d47eeefb8c03c9ed0694c67b1666eb35cb7f178c57a52041a19b0398fde -size 27426 +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 index f96db19f31c..1f73455d54f 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:232de960e0b5aa52d824dabdd33b544847d940054b1f90197d48cb3160a7d818 -size 12079 +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 index 0fd9e3adb47..0ca6f664380 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b44a0abf28f2b88bfc8d57e4b3c7ab248840f41190a389ac3c7b31e58202235 -size 11995 +oid sha256:5896eb5df6f5398bdb94e74280d0d5c04b6a5a53c2378b7377c60fa58eef544b +size 12013 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 index 687070419e6..b6c2706180c 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4311673b25a516c05ca019b72604deb2115a36195d987c116b47f94d9220d82 -size 6736 +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 index ea9751bafce..0b65cb998ba 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8e4c37ef03cdf83319be9ad61a8ff6c91a6a33404680e66d81736563c2a2ec9 -size 6872 +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 index 0b5876ab7ef..2aca39dc8d3 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ec521e4dcc594b59eb159aee39141f1dd94ba10fcacfaa33159c4280b8e22ea -size 27739 +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 index 696fea1cf90..5ff2f9b93de 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:168aef1da5c35f252d49f77866608a010c0ce42c1c1f6daa2ca0f115a5b5e3cf -size 26228 +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 index 15ea618fb71..a6872411518 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:260a44e1132a30150bd94b4232750b768cd1e74b1b149dc1e2d2261061ebf08d -size 11721 +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 index 0dbf6deb0d3..a1d1c5ec8e2 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:27297084473b61e5dc49ab399785433e73caaada5c29684d346e982a2476077d -size 11428 +oid sha256:8b93c08531efbeacb55f84b17ac793a7b226e5a2db7bc5072e94bc040cd1e9bc +size 11386 From fad8dce1667589b8ff4295990df0a5f5ef0113f1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 8 Jul 2026 18:16:13 +0200 Subject: [PATCH 30/30] Address PR review comments --- .../impl/userstatus/UserStatusView.kt | 4 -- .../userstatus/UserStatusPresenterTest.kt | 40 +++++-------------- .../libraries/matrix/impl/RustMatrixClient.kt | 1 + 3 files changed, 11 insertions(+), 34 deletions(-) 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 index dfc68ff4460..7169aaec666 100644 --- 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 @@ -22,7 +22,6 @@ 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.minimumInteractiveComponentSize import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -121,9 +120,6 @@ private fun EmptyStatusRow( modifier = Modifier.padding(vertical = 16.dp), ) }, - trailingContent = ListItemContent.Custom({ - Box(modifier = Modifier.minimumInteractiveComponentSize()) - }), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), modifier = modifier.clickable(onClick = onClick), ) 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 index 4a03a226078..3f78843b74e 100644 --- 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 @@ -7,13 +7,11 @@ package io.element.android.features.preferences.impl.userstatus -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test 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 @@ -23,9 +21,7 @@ class UserStatusPresenterTest { @Test fun `initial state - no status set`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { val state = awaitItem() assertThat(state.displayedStatus).isNull() assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden) @@ -34,9 +30,7 @@ class UserStatusPresenterTest { @Test fun `Open event transitions to ShowingPicker`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { val state = awaitItem() state.eventSink(UserStatusEvent.OpenPicker) assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker) @@ -45,9 +39,7 @@ class UserStatusPresenterTest { @Test fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { val state = awaitItem() state.eventSink(UserStatusEvent.OpenPicker) awaitItem() // ShowingPicker @@ -59,9 +51,7 @@ class UserStatusPresenterTest { @Test fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest { val client = FakeMatrixClient() - moleculeFlow(RecompositionMode.Immediate) { - createPresenter(client).present() - }.test { + createPresenter(client).test { val status = UserStatus(emoji = "πŸ’¬", text = "In a meeting") awaitItem().eventSink(UserStatusEvent.SetStatus(status)) val newState = awaitItem() @@ -73,9 +63,7 @@ class UserStatusPresenterTest { @Test fun `OpenCustomInput with no existing status uses defaults`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput @@ -87,9 +75,7 @@ class UserStatusPresenterTest { @Test fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest { val client = FakeMatrixClient() - moleculeFlow(RecompositionMode.Immediate) { - createPresenter(client).present() - }.test { + 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 @@ -103,9 +89,7 @@ class UserStatusPresenterTest { @Test fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("πŸš€")) val state = awaitItem() @@ -115,9 +99,7 @@ class UserStatusPresenterTest { @Test fun `CancelCustomInput goes back to Hidden`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) awaitItem().eventSink(UserStatusEvent.CancelCustomInput) // CustomInput state, fire Cancel assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden) @@ -127,9 +109,7 @@ class UserStatusPresenterTest { @Test fun `Clear event calls clearUserStatus and clears displayed status`() = runTest { val client = FakeMatrixClient() - moleculeFlow(RecompositionMode.Immediate) { - createPresenter(client).present() - }.test { + 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 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 af6762d4216..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 @@ -456,6 +456,7 @@ class RustMatrixClient( displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) }, ) ) + // Also update our session storage sessionStore.updateUserProfile( sessionId = sessionId.value, displayName = matrixUser.displayName,