Merge pull request #7094 from element-hq/feature/fga/set_user_status

WIP : Set user status
This commit is contained in:
ganfra
2026-07-09 14:34:23 +02:00
committed by GitHub
50 changed files with 956 additions and 36 deletions
@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
@@ -57,6 +58,7 @@ class PreferencesRootPresenter(
private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
private val sessionEnterpriseService: SessionEnterpriseService,
private val userStatusPresenter: Presenter<UserStatusState>,
) : Presenter<PreferencesRootState> {
@Composable
override fun present(): PreferencesRootState {
@@ -66,13 +68,14 @@ class PreferencesRootPresenter(
// Force a refresh of the profile
matrixClient.getUserProfile()
}
val isMultiAccountEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
}.collectAsState(initial = false)
val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false)
val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false)
val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
@@ -137,6 +140,7 @@ class PreferencesRootPresenter(
return PreferencesRootState(
myUser = matrixUser.value,
userStatusState = userStatusState,
version = remember { versionFormatter.get() },
deviceId = matrixClient.deviceId,
isMultiAccountEnabled = isMultiAccountEnabled,
@@ -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,
@@ -11,6 +11,9 @@ package io.element.android.features.preferences.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -64,8 +67,9 @@ open class PreferencesRootStateProvider : PreviewParameterProvider<PreferencesRo
showDeveloperSettings = true,
canDeactivateAccount = true,
),
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
// Minimal state
aPreferencesRootState(),
aPreferencesRootState(userStatusState = null),
)
}
@@ -86,6 +90,7 @@ fun aPreferencesRootState(
nbOfBlockedUsers: Int = 0,
showLabsItem: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
userStatusState: UserStatusState? = aUserStatusState(),
snackbarMessage: SnackbarMessage? = null,
eventSink: (PreferencesRootEvent) -> Unit = {},
) = PreferencesRootState(
@@ -105,6 +110,7 @@ fun aPreferencesRootState(
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
userStatusState = userStatusState,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
)
@@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -23,6 +24,8 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent
@@ -85,10 +88,10 @@ fun PreferencesRootView(
state = state,
onAddAccountClick = onAddAccountClick,
)
} else {
HorizontalDivider()
}
// User status will be added here
if (state.userStatusState != null) {
UserStatusSection(state.userStatusState, showTopDivider = !state.isMultiAccountEnabled)
}
// 'Account' section
ManageAccountSection(
state = state,
@@ -129,6 +132,27 @@ fun PreferencesRootView(
}
}
@Composable
private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState,
showTopDivider: Boolean,
) {
if (showTopDivider) {
HorizontalDivider(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
}
UserStatusView(
state = userStatusState,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
}
@Composable
private fun ColumnScope.MultiAccountSection(
state: PreferencesRootState,
@@ -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<UserStatusState>
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import io.element.android.libraries.matrix.api.user.UserStatus
sealed interface UserStatusEvent {
data object OpenPicker : UserStatusEvent
data object DismissPicker : UserStatusEvent
data class SetStatus(val status: UserStatus) : UserStatusEvent
data object OpenCustomInput : UserStatusEvent
data object CancelCustomInput : UserStatusEvent
data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent
data object ClearStatus : UserStatusEvent
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.launch
@Inject
class UserStatusPresenter(
private val matrixClient: MatrixClient,
) : Presenter<UserStatusState> {
@Composable
override fun present(): UserStatusState {
val userProfile by matrixClient.userProfile.collectAsState()
var pickerState by remember { mutableStateOf<UserStatusPickerState>(UserStatusPickerState.Hidden) }
val customTextFieldState = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope()
fun handleEvent(event: UserStatusEvent) {
when (event) {
UserStatusEvent.OpenPicker -> pickerState = UserStatusPickerState.ShowingPicker
UserStatusEvent.DismissPicker -> pickerState = UserStatusPickerState.Hidden
UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus
if (raw != null) {
customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text)
} else {
customTextFieldState.clearText()
}
pickerState = UserStatusPickerState.CustomInput(
emoji = raw?.emoji ?: "😀",
textFieldState = customTextFieldState,
)
}
is UserStatusEvent.SetStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
}
UserStatusEvent.ClearStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() }
}
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
is UserStatusEvent.UpdateCustomEmoji -> {
val current = pickerState as? UserStatusPickerState.CustomInput ?: return
pickerState = current.copy(emoji = event.emoji)
}
}
}
return UserStatusState(
displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus,
pickerState = pickerState,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
data class UserStatusState(
val displayedStatus: DisplayedStatus?,
val rawStatus: UserStatus?,
val pickerState: UserStatusPickerState,
val eventSink: (UserStatusEvent) -> Unit,
)
@Immutable
sealed interface UserStatusPickerState {
data object Hidden : UserStatusPickerState
data object ShowingPicker : UserStatusPickerState
data class CustomInput(
val emoji: String,
val textFieldState: TextFieldState,
) : UserStatusPickerState
}
enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) {
IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting),
FOCUS_TIME("💡", R.string.common_user_status_focus_time),
ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road),
BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back),
AWAY("🌴", R.string.common_user_status_away),
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusState> {
override val values get() = sequenceOf(
aUserStatusState(),
aUserStatusState(displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away"))),
aUserStatusState(displayedStatus = DisplayedStatus.InCall(callJoinedTs = 0L)),
aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker),
aUserStatusState(
displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")),
rawStatus = UserStatus("🌴", "Away"),
pickerState = UserStatusPickerState.ShowingPicker,
),
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState())),
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))),
)
}
fun aUserStatusState(
displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState(
displayedStatus = displayedStatus,
rawStatus = rawStatus,
pickerState = pickerState,
eventSink = eventSink,
)
@@ -0,0 +1,315 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.maxLength
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun UserStatusView(
state: UserStatusState,
modifier: Modifier = Modifier,
) {
when (val pickerState = state.pickerState) {
UserStatusPickerState.Hidden, UserStatusPickerState.ShowingPicker -> {
val displayedStatus = state.displayedStatus
if (displayedStatus != null) {
CurrentStatusRow(
displayedStatus = displayedStatus,
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
onClear = { state.eventSink(UserStatusEvent.ClearStatus) },
modifier = modifier,
)
} else {
EmptyStatusRow(
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
modifier = modifier,
)
}
if (pickerState == UserStatusPickerState.ShowingPicker) {
UserStatusPickerBottomSheet(
currentRawStatus = state.rawStatus,
onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) },
onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) },
onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) },
)
}
}
is UserStatusPickerState.CustomInput -> {
CustomStatusInputRow(
emoji = pickerState.emoji,
textFieldState = pickerState.textFieldState,
rawStatus = state.rawStatus,
onConfirm = {
state.eventSink(
UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString()))
)
},
onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) },
modifier = modifier,
)
}
}
}
@Composable
private fun EmptyStatusRow(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.screen_preferences_user_status_placeholder),
modifier = Modifier.padding(vertical = 16.dp),
)
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())),
modifier = modifier.clickable(onClick = onClick),
)
}
@Composable
private fun CurrentStatusRow(
displayedStatus: DisplayedStatus,
onClick: () -> Unit,
onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
val (emoji, text) = when (displayedStatus) {
is DisplayedStatus.UserSet -> displayedStatus.status.emoji to displayedStatus.status.text
is DisplayedStatus.InCall -> "🎧" to stringResource(R.string.common_user_status_on_a_call)
}
ListItem(
headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) },
leadingContent = ListItemContent.Custom { EmojiText(emoji) },
trailingContent = ListItemContent.Custom({
IconButton(
onClick = onClear,
// Remove the extra padding caused by IconButton
modifier = Modifier.offset(x = 12.dp)
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear),
)
}
}),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun EmojiText(emoji: String) {
// Ensure the emoji doesn't scale with fontSize to match icons size
val fontSize = with(LocalDensity.current) { 16.dp.toSp() }
Text(
modifier = Modifier.sizeIn(minWidth = 24.dp, minHeight = 24.dp),
text = emoji,
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSize),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UserStatusPickerBottomSheet(
currentRawStatus: UserStatus?,
onDismiss: () -> Unit,
onSelectPredefinedStatus: (UserStatus) -> Unit,
onSelectCustomStatus: () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded)
)
val coroutineScope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
scrollable = true,
modifier = modifier,
) {
PredefinedUserStatus.entries.forEach { predefined ->
val label = stringResource(predefined.labelRes)
val predefinedUserStatus = UserStatus(emoji = predefined.emoji, text = label)
val isSelected = currentRawStatus == predefinedUserStatus
ListItem(
headlineContent = { Text(text = label) },
leadingContent = ListItemContent.Custom { EmojiText(predefined.emoji) },
trailingContent = if (isSelected) {
ListItemContent.Icon(IconSource.Vector(CompoundIcons.Check()), tintColor = ElementTheme.colors.iconAccentPrimary)
} else {
null
},
onClick = {
sheetState.hide(coroutineScope) {
onSelectPredefinedStatus(predefinedUserStatus)
}
},
)
}
ListItem(
headlineContent = {
Text(text = stringResource(R.string.common_user_status_custom))
},
leadingContent = ListItemContent.Custom { EmojiText("✏️") },
onClick = {
sheetState.hide(coroutineScope) {
onSelectCustomStatus()
}
},
)
}
}
private const val CUSTOM_STATUS_TEXT_MAX_LENGTH = 30
@Composable
private fun CustomStatusInputRow(
emoji: String,
textFieldState: TextFieldState,
rawStatus: UserStatus?,
onConfirm: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } }
val hasChanges by remember(emoji, rawStatus) {
derivedStateOf {
val text = textFieldState.text.toString()
text.isNotBlank() && UserStatus(emoji, text) != rawStatus
}
}
val saveLabel = stringResource(CommonStrings.action_save)
val cancelLabel = stringResource(CommonStrings.action_cancel)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
ListItem(
headlineContent = {
TextField(
modifier = Modifier.focusRequester(focusRequester),
state = textFieldState,
placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint),
inputTransformation = InputTransformation.maxLength(CUSTOM_STATUS_TEXT_MAX_LENGTH),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onKeyboardAction = { if (hasChanges) onConfirm() },
lineLimits = TextFieldLineLimits.SingleLine,
trailingIcon = if (hasText) {
{
Box(modifier = Modifier.clickable { textFieldState.clearText() }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel),
modifier = Modifier.size(22.dp),
)
}
}
} else {
null
}
)
},
trailingContent = ListItemContent.Custom({
// Layout measures both buttons to determine the max natural width,
// then places only the active one.
Layout(
content = {
TextButton(onClick = onConfirm, text = saveLabel)
TextButton(onClick = onCancel, text = cancelLabel)
}
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val maxWidth = placeables.maxOf { it.width }
val maxHeight = placeables.maxOf { it.height }
layout(maxWidth, maxHeight) {
val active = if (hasChanges) placeables[0] else placeables[1]
active.placeRelative(
x = (maxWidth - active.width) / 2,
y = (maxHeight - active.height) / 2,
)
}
}
}),
leadingContent = ListItemContent.Custom({
Surface(
shape = CircleShape,
border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary),
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.clickable { },
) {
Box(contentAlignment = Alignment.Center) {
EmojiText(emoji)
}
}
}),
modifier = modifier,
)
}
@PreviewsDayNight
@Composable
internal fun UserStatusViewPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview {
UserStatusView(state = state)
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<resources>
<string name="common_user_status_in_a_meeting">In a meeting</string>
<string name="common_user_status_focus_time">Focus time</string>
<string name="common_user_status_on_the_road">On the road</string>
<string name="common_user_status_be_right_back">Be right back</string>
<string name="common_user_status_away">Away</string>
<string name="common_user_status_on_a_call">On a call</string>
<string name="common_user_status_custom">Custom…</string>
<string name="screen_preferences_user_status_placeholder">What\'s your status?</string>
<string name="screen_preferences_user_status_custom_hint">Custom status…</string>
</resources>
@@ -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() },
)
}
@@ -12,8 +12,11 @@ package io.element.android.features.preferences.impl.root
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -199,7 +202,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onOpenAnalytics = callback,
)
clickOn(CommonStrings.common_analytics)
val text = activity!!.getString(CommonStrings.common_analytics)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -226,7 +230,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onOpenRageShake = callback,
)
clickOn(CommonStrings.common_report_a_problem)
val text = activity!!.getString(CommonStrings.common_report_a_problem)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -281,7 +286,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onOpenDeveloperSettings = callback,
)
clickOn(CommonStrings.common_developer_options)
val text = activity!!.getString(CommonStrings.common_developer_options)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -389,7 +395,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onSignOutClick = callback,
)
clickOn(CommonStrings.action_signout)
val text = activity!!.getString(CommonStrings.action_signout)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -404,7 +411,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onDeactivateClick = callback,
)
clickOn(CommonStrings.action_delete_account)
val text = activity!!.getString(CommonStrings.action_delete_account)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -430,7 +438,7 @@ class PreferencesRootViewTest : RobolectricTest() {
eventSink = eventsRecorder,
),
)
onNodeWithText(version).performClick()
onNodeWithText(version).performScrollTo().performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick)
}
}
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class UserStatusPresenterTest {
private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) =
UserStatusPresenter(matrixClient = client)
@Test
fun `initial state - no status set`() = runTest {
createPresenter().test {
val state = awaitItem()
assertThat(state.displayedStatus).isNull()
assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden)
}
}
@Test
fun `Open event transitions to ShowingPicker`() = runTest {
createPresenter().test {
val state = awaitItem()
state.eventSink(UserStatusEvent.OpenPicker)
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker)
}
}
@Test
fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest {
createPresenter().test {
val state = awaitItem()
state.eventSink(UserStatusEvent.OpenPicker)
awaitItem() // ShowingPicker
state.eventSink(UserStatusEvent.DismissPicker)
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
}
}
@Test
fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest {
val client = FakeMatrixClient()
createPresenter(client).test {
val status = UserStatus(emoji = "💬", text = "In a meeting")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(client.setUserStatusCalled).isTrue()
}
}
@Test
fun `OpenCustomInput with no existing status uses defaults`() = runTest {
createPresenter().test {
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
val state = awaitItem()
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
assertThat(pickerState.emoji).isEqualTo("😀")
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("")
}
}
@Test
fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest {
val client = FakeMatrixClient()
createPresenter(client).test {
val status = UserStatus(emoji = "🌴", text = "Away")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
val state = awaitItem()
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
assertThat(pickerState.emoji).isEqualTo("🌴")
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("Away")
}
}
@Test
fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest {
createPresenter().test {
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("🚀"))
val state = awaitItem()
assertThat((state.pickerState as UserStatusPickerState.CustomInput).emoji).isEqualTo("🚀")
}
}
@Test
fun `CancelCustomInput goes back to Hidden`() = runTest {
createPresenter().test {
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
awaitItem().eventSink(UserStatusEvent.CancelCustomInput) // CustomInput state, fire Cancel
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
}
}
@Test
fun `Clear event calls clearUserStatus and clears displayed status`() = runTest {
val client = FakeMatrixClient()
createPresenter(client).test {
val status = UserStatus(emoji = "☕", text = "Be right back")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
awaitItem().eventSink(UserStatusEvent.ClearStatus)
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(clearedState.displayedStatus).isNull()
assertThat(client.clearUserStatusCalled).isTrue()
}
}
}
@@ -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?,
@@ -152,4 +152,11 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
UserStatus(
key = "feature.user_status",
title = "User status",
description = "Allow users to set a status (e.g. In a meeting, Away) visible to their contacts.",
defaultValue = { false },
isFinished = false,
),
}
@@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -86,6 +87,10 @@ interface MatrixClient {
suspend fun setDisplayName(displayName: String): Result<Unit>
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>
suspend fun setUserStatus(status: UserStatus): Result<Unit>
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
suspend fun clearUserStatus(): Result<Unit>
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.user
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
@Immutable
sealed interface DisplayedStatus : Parcelable {
/** Status set manually by the user via m.status. */
@Parcelize
data class UserSet(val status: UserStatus) : DisplayedStatus
/** Status set automatically when the user is in a call via m.call. */
@Parcelize
data class InCall(
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
val callJoinedTs: Long,
) : DisplayedStatus
}
@@ -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
@@ -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
@@ -49,8 +49,10 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
@@ -137,6 +139,7 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
@Suppress("LargeClass")
class RustMatrixClient(
override val sessionPaths: SessionPaths,
private val innerClient: Client,
@@ -260,6 +263,8 @@ class RustMatrixClient(
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
@Volatile private var localUserStatus: UserStatus? = null
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser(
userId = sessionId,
@@ -445,7 +450,12 @@ class RustMatrixClient(
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { matrixUser ->
_userProfile.emit(matrixUser)
_userProfile.emit(
matrixUser.copy(
rawStatus = localUserStatus,
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
)
)
// Also update our session storage
sessionStore.updateUserProfile(
sessionId = sessionId.value,
@@ -476,6 +486,28 @@ class RustMatrixClient(
runCatchingExceptions { innerClient.removeAvatar() }
}
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = status
_userProfile.emit(
_userProfile.value.copy(
rawStatus = status,
displayedStatus = DisplayedStatus.UserSet(status),
)
)
Result.success(Unit)
}
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = null
_userProfile.emit(
_userProfile.value.copy(
rawStatus = null,
displayedStatus = null,
)
)
Result.success(Unit)
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.joinRoomById(roomId.value).destroy()
@@ -42,8 +42,10 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
@@ -131,6 +133,12 @@ class FakeMatrixClient(
private set
var removeAvatarCalled: Boolean = false
private set
var setUserStatusCalled: Boolean = false
private set
var clearUserStatusCalled: Boolean = false
private set
var setUserStatusResult: Result<Unit> = Result.success(Unit)
var clearUserStatusResult: Result<Unit> = Result.success(Unit)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
override val userProfile: StateFlow<MatrixUser> = _userProfile
@@ -253,6 +261,28 @@ class FakeMatrixClient(
return removeAvatarResult
}
override suspend fun setUserStatus(status: UserStatus): Result<Unit> {
setUserStatusCalled = true
if (setUserStatusResult.isSuccess) {
_userProfile.emit(_userProfile.value.copy(
rawStatus = status,
displayedStatus = DisplayedStatus.UserSet(status),
))
}
return setUserStatusResult
}
override suspend fun clearUserStatus(): Result<Unit> {
clearUserStatusCalled = true
if (clearUserStatusResult.isSuccess) {
_userProfile.emit(_userProfile.value.copy(
rawStatus = null,
displayedStatus = null,
))
}
return clearUserStatusResult
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:837ebeb30aebfb154e98b1b4603156cf0ac877b58efae17d534aaf269ce8ba10
size 20037
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07ad7e092d6b8e41064bcbce131ef53d70a552dd6f9292dd286dd7c10948cf9a
size 20459
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c14e7ad5b7c320873dab0b97a6261b2b62a7405d133e9c53fe2184e9757becac
size 9203
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:35f0fb99a74477ebfa53a019ded68043035de9ba0f86a6a71b032137a125d107
size 7168
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6432133a87271013cab796baa7d48ab8e96f65f049b92a9074ec03f540e11545
size 7269
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ebc88a04da18e07b5f94cc52f75ad033fa01917de6c32ac77a1b400fd27c62cd
size 28944
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3cff5ee1c0cc217b20c68bb4f4d2335ab03e9dd8d16045550358e748002c538b
size 27460
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b5dc5c492ed3fd552e77c0be85b639d55bc7e976f15b761f927b0b80ecca601d
size 12115
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5896eb5df6f5398bdb94e74280d0d5c04b6a5a53c2378b7377c60fa58eef544b
size 12013
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:343ddb8692135fb051766c340a4ac6143a5dc57917f0cc16fe11678f2b9bae6e
size 8774
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b4b5eb36080cc5750c4cd36ae66ba618c033e794fdf0a4fb5afbae5e1fa10824
size 6789
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bf614dc03afdb990baae40b020bd3f3d6396226b45fc4eee18f2d55100df5bff
size 6918
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fff529404e3219d00ded2f701066c48506b96512b08b1750665d3c2c0a75ed97
size 27700
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1017de34abdb57805946efdd6d486ab76adecdc458d17eb9b1128be0dbe8ff00
size 26227
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7ce8a8c6b4d1692e4b127b7f17f507552666c631893025082c385b93d835a73
size 11726
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8b93c08531efbeacb55f84b17ac793a7b226e5a2db7bc5072e94bc040cd1e9bc
size 11386