Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bad32cb
Add UserStatus and DisplayedStatus types to matrix API
ganfra Jun 12, 2026
5697c96
Add KDoc to InCall.callJoinedTs and clearUserStatus
ganfra Jun 12, 2026
742902e
Add setUserStatus/clearUserStatus stubs to FakeMatrixClient
ganfra Jun 12, 2026
f4c79be
Stub setUserStatus/clearUserStatus in-memory in RustMatrixClient
ganfra Jun 12, 2026
42966b3
Add user status string resources and PredefinedUserStatus enum
ganfra Jun 12, 2026
1640adc
Fix thread-safety and dispatcher consistency in user status stubs
ganfra Jun 12, 2026
eca45b5
Add UserStatusState and UserStatusEvent
ganfra Jun 12, 2026
0528b78
Add UserStatusPresenter with full test coverage
ganfra Jun 12, 2026
af5a237
Add UserStatusView composables with previews
ganfra Jun 12, 2026
6c34d64
Wire UserStatusPresenter and UserStatusRow into PreferencesRoot
ganfra Jun 12, 2026
7309e49
Wire UserStatusPresenter and UserStatusRow into PreferencesRoot
ganfra Jun 12, 2026
eeaaac2
Move custom status emoji/text into CustomInput state (MVI)
ganfra Jun 15, 2026
73a8a3d
Use TextFieldState in CustomInput state for proper MVI text handling
ganfra Jun 15, 2026
57617a3
Add TextFieldState overload to design system TextField, use it in Use…
ganfra Jun 15, 2026
167c1ce
Add clear button in custom status text field, toggle Cancel/Save on t…
ganfra Jun 15, 2026
889fd08
Fix button width jitter using rememberTextMeasurer to derive stable m…
ganfra Jun 16, 2026
078b63f
Move preview cases to UserStatusStateProvider, use @PreviewParameter …
ganfra Jun 16, 2026
2a0e416
Match row heights: add vertical padding to plain text rows to align w…
ganfra Jun 16, 2026
553a478
Rename Event
ganfra Jun 17, 2026
1cfd3e4
Improve UI/UX for setting status
ganfra Jun 17, 2026
dd24e14
Gate user status behind UserStatus feature flag
ganfra Jun 17, 2026
d29dad4
Extract custom status text max length into a constant
ganfra Jun 17, 2026
061fd82
Clip avatar to a circle on the user status screen
ganfra Jun 17, 2026
be872e6
Move user status section below multi-account in preferences
ganfra Jun 18, 2026
4a143c1
Format code
ganfra Jun 18, 2026
29b8068
Address quality issues on user status
ganfra Jun 19, 2026
f9a8acf
Update screenshots
ElementBot Jun 25, 2026
bfe2169
Tweak some UserStatusView ui
ganfra Jul 2, 2026
301f930
Update screenshots
ElementBot Jul 2, 2026
d25362d
Merge branch 'develop' into feature/fga/set_user_status
ganfra Jul 8, 2026
fad8dce
Address PR review comments
ganfra Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 ->
Expand Down Expand Up @@ -137,6 +140,7 @@ class PreferencesRootPresenter(

return PreferencesRootState(
myUser = matrixUser.value,
userStatusState = userStatusState,
version = remember { versionFormatter.get() },
deviceId = matrixClient.deviceId,
isMultiAccountEnabled = isMultiAccountEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
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
import kotlinx.collections.immutable.ImmutableList

data class PreferencesRootState(
val myUser: MatrixUser,
val userStatusState: UserStatusState?,
val version: String,
val deviceId: DeviceId?,
val isMultiAccountEnabled: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
}

Expand All @@ -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(
Expand All @@ -105,6 +110,7 @@ fun aPreferencesRootState(
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
userStatusState = userStatusState,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 18 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusBindingContainer.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this interface functional or replace it with a function type.

See more on https://sonarcloud.io/project/issues?id=element-x-android&issues=AZ7-YJyQK0Fs6AeJ9fU2&open=AZ7-YJyQK0Fs6AeJ9fU2&pullRequest=7094

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our current convention is to name this class UserStatusModule. But maybe we should change the convention (probably tight to the old DI API) and rename the other interfaces at some point.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't have strong opinion, I can use Module and we make the change later on the whole codebase

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep it like this then, and rename the other ones separately

@Binds
fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter<UserStatusState>
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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),
}
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading