Merge pull request #7517 from element-hq/feature/bma/removeUserStatusFeatureFlag

Enable user status feature and hide status text in the timeline
This commit is contained in:
Benoit Marty
2026-08-24 18:08:10 +02:00
committed by GitHub
25 changed files with 106 additions and 120 deletions
@@ -30,8 +30,6 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -67,7 +65,6 @@ class LoggedInPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor, private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
private val featureFlagService: FeatureFlagService,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter = private val localNetworkPermissionsPresenter: PermissionsPresenter =
@@ -124,14 +121,10 @@ class LoggedInPresenter(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support. // Keep automatic call status (m.call) in sync with homeserver support.
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus) val enabled = matrixClient.isUserStatusSupported().getOrDefault(false)
.onEach { isUserStatusEnabled ->
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled) matrixClient.enableAutomaticCallStatus(enabled)
} }
.launchIn(this)
}
val networkConnectivity by networkMonitor.connectivity.collectAsState() val networkConnectivity by networkMonitor.connectivity.collectAsState()
LaunchedEffect(networkConnectivity) { LaunchedEffect(networkConnectivity) {
@@ -17,9 +17,6 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -131,7 +128,6 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = FakeFeatureFlagService(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test { ).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
@@ -362,9 +358,6 @@ class LoggedInPresenterTest {
} }
createLoggedInPresenter( createLoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to true),
),
).test { ).test {
cancelAndConsumeRemainingEvents() cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda) assert(enableAutomaticCallStatusLambda)
@@ -374,7 +367,7 @@ class LoggedInPresenterTest {
} }
@Test @Test
fun `present - reacts to user status flag being toggled at runtime`() = runTest { fun `present - enables automatic call status when server supports it`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { } val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) }, accountManagementUrlResult = { Result.success(null) },
@@ -382,23 +375,13 @@ class LoggedInPresenterTest {
).apply { ).apply {
isUserStatusSupportedResult = Result.success(true) isUserStatusSupportedResult = Result.success(true)
} }
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to false),
)
createLoggedInPresenter( createLoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
featureFlagService = featureFlagService,
).test { ).test {
awaitItem()
// Turning the flag on at runtime enables it.
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
cancelAndConsumeRemainingEvents() cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda) assert(enableAutomaticCallStatusLambda)
.isCalledExactly(2) .isCalledOnce()
.withSequence( .with(value(true))
listOf(value(false)),
listOf(value(true)),
)
} }
} }
@@ -418,7 +401,6 @@ class LoggedInPresenterTest {
), ),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): LoggedInPresenter { ): LoggedInPresenter {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
@@ -430,7 +412,6 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = featureFlagService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
) )
} }
@@ -76,10 +76,7 @@ class PreferencesRootPresenter(
}.collectAsState(initial = false) }.collectAsState(initial = false)
val isUserStatusSupported by produceState(false) { val isUserStatusSupported by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus) value = matrixClient.isUserStatusSupported().getOrDefault(false)
.collect { isUserStatusEnabled ->
value = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
}
} }
val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null
@@ -152,13 +152,6 @@ enum class FeatureFlags(
defaultValue = { false }, defaultValue = { false },
isFinished = 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,
),
MessageSearch( MessageSearch(
key = "feature.message_search", key = "feature.message_search",
title = "Message search", title = "Message search",
@@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
@@ -163,9 +164,10 @@ fun aProfileDetailsReady(
displayName: String? = USER_NAME_SENDER, displayName: String? = USER_NAME_SENDER,
displayNameAmbiguous: Boolean = false, displayNameAmbiguous: Boolean = false,
avatarUrl: String? = null, avatarUrl: String? = null,
displayedStatus: DisplayedStatus? = null
) = ProfileDetails.Ready( ) = ProfileDetails.Ready(
displayName = displayName, displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
displayedStatus = null, displayedStatus = displayedStatus,
) )
@@ -9,12 +9,15 @@
package io.element.android.libraries.matrix.ui.messages.sender package io.element.android.libraries.matrix.ui.messages.sender
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -24,7 +27,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.ui.model.toText import io.element.android.libraries.matrix.ui.model.toEmojiText
// https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0 // https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0
@Composable @Composable
@@ -61,7 +64,7 @@ fun SenderName(
modifier = Modifier modifier = Modifier
.clipToBounds() .clipToBounds()
.alignByBaseline(), .alignByBaseline(),
text = userStatus.toText(), text = userStatus.toEmojiText(),
style = ElementTheme.typography.fontBodyMdMedium, style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
maxLines = 1, maxLines = 1,
@@ -136,9 +139,27 @@ private fun RowScope.SecondaryText(
internal fun SenderNamePreview( internal fun SenderNamePreview(
@PreviewParameter(SenderNameDataPreviewParam::class) senderNameData: SenderNameData, @PreviewParameter(SenderNameDataPreviewParam::class) senderNameData: SenderNameData,
) = ElementPreview { ) = ElementPreview {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
sequenceOf(
SenderNameMode.Timeline(mainColor = Color.Red),
SenderNameMode.Reply,
SenderNameMode.ActionList,
).forEach { mode ->
Column {
Text(
"Rendering in mode ${mode::class.simpleName}:",
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
)
SenderName( SenderName(
senderId = senderNameData.userId, senderId = senderNameData.userId,
senderProfile = senderNameData.profileDetails, senderProfile = senderNameData.profileDetails,
senderNameMode = senderNameData.senderNameMode, senderNameMode = mode,
) )
} }
}
}
}
@@ -8,54 +8,65 @@
package io.element.android.libraries.matrix.ui.messages.sender package io.element.android.libraries.matrix.ui.messages.sender
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
data class SenderNameData( data class SenderNameData(
val userId: UserId, val userId: UserId,
val profileDetails: ProfileDetails, val profileDetails: ProfileDetails,
val senderNameMode: SenderNameMode,
) )
open class SenderNameDataPreviewParam : PreviewParameterProvider<SenderNameData> { open class SenderNameDataPreviewParam : PreviewParameterProvider<SenderNameData> {
override val values: Sequence<SenderNameData> override val values: Sequence<SenderNameData>
get() = sequenceOf( get() = sequenceOf(
SenderNameMode.Timeline(mainColor = Color.Red), aSenderNameData(),
SenderNameMode.Reply,
SenderNameMode.ActionList,
)
.flatMap { senderNameMode ->
sequenceOf(
aSenderNameData( aSenderNameData(
senderNameMode = senderNameMode,
),
aSenderNameData(
senderNameMode = senderNameMode,
displayNameAmbiguous = true, displayNameAmbiguous = true,
), ),
SenderNameData( aSenderNameData(
senderNameMode = senderNameMode,
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
profileDetails = ProfileDetails.Unavailable, profileDetails = ProfileDetails.Unavailable,
), ),
aSenderNameData(
displayName = null,
),
aSenderNameData(
displayedStatus = DisplayedStatus.UserSet(
status = UserStatus(
emoji = "😀",
text = "Should not be rendered",
),
),
),
aSenderNameData(
displayedStatus = DisplayedStatus.InCall(0L),
),
aSenderNameData(
displayNameAmbiguous = true,
displayedStatus = DisplayedStatus.UserSet(
status = UserStatus(
emoji = "😀",
text = "Should not be rendered",
),
),
),
) )
} }
}
private fun aSenderNameData( private fun aSenderNameData(
senderNameMode: SenderNameMode, userId: UserId = UserId("@alice:example.com"),
displayName: String? = "Alice",
displayNameAmbiguous: Boolean = false, displayNameAmbiguous: Boolean = false,
displayedStatus: DisplayedStatus? = null, displayedStatus: DisplayedStatus? = null,
) = SenderNameData( profileDetails: ProfileDetails = aProfileDetailsReady(
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"), displayName = displayName,
profileDetails = ProfileDetails.Ready(
displayName = "Alice ${senderNameMode.javaClass.simpleName}",
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = null,
displayedStatus = displayedStatus, displayedStatus = displayedStatus,
), )
senderNameMode = senderNameMode, ) = SenderNameData(
userId = userId,
profileDetails = profileDetails,
) )