Merge pull request #7328 from element-hq/feature/fga/user_status_last_pieces

User status last pieces
This commit is contained in:
ganfra
2026-08-11 11:11:12 +02:00
committed by GitHub
155 changed files with 979 additions and 357 deletions
+1
View File
@@ -60,6 +60,7 @@ dependencies {
testCommonDependencies(libs)
testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
@@ -30,6 +30,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
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.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -65,6 +67,7 @@ class LoggedInPresenter(
private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
private val featureFlagService: FeatureFlagService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter =
@@ -120,6 +123,16 @@ class LoggedInPresenter(
}.launchIn(this)
}
LaunchedEffect(Unit) {
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support.
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.onEach { isUserStatusEnabled ->
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled)
}
.launchIn(this)
}
val networkConnectivity by networkMonitor.connectivity.collectAsState()
LaunchedEffect(networkConnectivity) {
if (networkConnectivity == NetworkStatus.Connected) {
@@ -17,6 +17,9 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
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.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -48,6 +51,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -127,6 +131,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = FakeFeatureFlagService(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
@@ -346,6 +351,57 @@ class LoggedInPresenterTest {
}
}
@Test
fun `present - does not enable automatic call status when server does not support it`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(false)
}
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to true),
),
).test {
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledOnce()
.with(value(false))
}
}
@Test
fun `present - reacts to user status flag being toggled at runtime`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(true)
}
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to false),
)
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = featureFlagService,
).test {
awaitItem()
// Turning the flag on at runtime enables it.
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledExactly(2)
.withSequence(
listOf(value(false)),
listOf(value(true)),
)
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
@@ -362,6 +418,7 @@ class LoggedInPresenterTest {
),
buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = matrixClient,
@@ -373,6 +430,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = featureFlagService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
)
}
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
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.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toEmojiText
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -151,7 +171,7 @@ fun HomeTopBar(
},
// We want a 16dp left padding for the navigationIcon :
// 4dp from default TopAppBarHorizontalPadding
// 8dp from AccountIcon default padding (because of IconButton)
// 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
// 4dp extra padding using left insets
windowInsets = WindowInsets(left = 4.dp),
)
@@ -316,19 +336,36 @@ private fun AccountIcon(
modifier: Modifier = Modifier,
) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
IconButton(
modifier = modifier.then(testTag),
onClick = onClick,
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.then(testTag)
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,
onClick = onClick,
indication = ripple(bounded = false),
),
contentAlignment = Alignment.Center,
) {
Box {
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -339,11 +376,69 @@ private fun AccountIcon(
null
},
)
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
private val statusEmojiBadgeSize = 20.dp
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground(
parentSize: Dp,
layoutDirection: LayoutDirection,
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
)
}
@Composable
private fun StatusEmojiBadge(
emoji: String,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
// Ensure the emoji size is not affected by the user's font scale settings
val fixedFontScaleDensity = remember(density.density) {
Density(density = density.density, fontScale = 1f)
}
Box(
modifier = modifier
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text(
text = emoji,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
}
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
currentUserAndNeighbors = persistentListOf(
aMatrixUser(
id = "@id:domain",
displayName = USER_NAME_ALICE,
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
)
),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
@@ -125,6 +125,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
@@ -207,11 +208,7 @@ fun TimelineItemEventRow(
}
fun onUserDataClick() {
val sender = MatrixUser(
userId = event.senderId,
displayName = event.senderProfile.getDisplayName(),
avatarUrl = event.senderProfile.getAvatarUrl(),
)
val sender = event.senderProfile.toMatrixUser(event.senderId)
onUserDataClick(sender)
}
@@ -74,8 +74,14 @@ class PreferencesRootPresenter(
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 isUserStatusSupported by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.collect { isUserStatusEnabled ->
value = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
}
}
val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
@@ -9,9 +9,12 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -25,7 +28,10 @@ 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.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -71,71 +77,87 @@ fun PreferencesRootView(
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
// Include pref from other modules
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
UserPreferences(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
},
matrixUser = state.myUser,
)
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
Box(modifier = modifier) {
// Include pref from other modules
PreferencePage(
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
UserPreferences(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
},
matrixUser = state.myUser,
)
}
if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
)
}
)
if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
}
)
}
state.userStatusState?.let {
UserStatusUpdateIndicator(it.updateStatusAction)
}
}
}
@Composable
private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<Unit>) {
AsyncActionIndicator(
asyncAction = updateStatusAction,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding(),
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) },
)
}
@Composable
private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState,
@@ -18,7 +18,9 @@ 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.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
import io.element.android.libraries.matrix.api.MatrixClient
@@ -38,6 +40,7 @@ class UserStatusPresenter(
var isEmojiPickerVisible by remember { mutableStateOf(false) }
val customTextFieldState = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope()
val updateStatusAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: UserStatusEvent) {
when (event) {
@@ -47,11 +50,15 @@ class UserStatusPresenter(
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
is UserStatusEvent.SetStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) }
}
}
UserStatusEvent.ClearStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() }
coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() }
}
}
UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus
@@ -93,6 +100,7 @@ class UserStatusPresenter(
displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus,
pickerState = effectivePickerState,
updateStatusAction = updateStatusAction.value,
eventSink = ::handleEvent,
)
}
@@ -11,6 +11,7 @@ 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.architecture.AsyncAction
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
@@ -19,6 +20,7 @@ data class UserStatusState(
val displayedStatus: DisplayedStatus?,
val rawStatus: UserStatus?,
val pickerState: UserStatusPickerState,
val updateStatusAction: AsyncAction<Unit>,
val eventSink: (UserStatusEvent) -> Unit,
)
@@ -9,6 +9,7 @@ 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.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
@@ -44,10 +45,12 @@ fun aUserStatusState(
displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
updateStatusAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState(
displayedStatus = displayedStatus,
rawStatus = rawStatus,
pickerState = pickerState,
updateStatusAction = updateStatusAction,
eventSink = eventSink,
)
@@ -8,9 +8,11 @@
package io.element.android.features.preferences.impl.userstatus
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
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.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
@@ -61,6 +63,24 @@ class UserStatusPresenterTest {
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.setUserStatusCalled).isTrue()
}
}
@Test
fun `Set event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
setUserStatusResult = Result.failure(AN_EXCEPTION)
}
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)
// On failure the SDK does not emit a new profile, so nothing is displayed.
assertThat(newState.displayedStatus).isNull()
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.setUserStatusCalled).isTrue()
}
}
@@ -121,6 +141,25 @@ class UserStatusPresenterTest {
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(clearedState.displayedStatus).isNull()
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.clearUserStatusCalled).isTrue()
}
}
@Test
fun `Clear event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
clearUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "☕", text = "Be right back")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
awaitItem().eventSink(UserStatusEvent.ClearStatus)
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// Clear failed, so the previously set status is still displayed.
assertThat(clearedState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.clearUserStatusCalled).isTrue()
}
}
@@ -0,0 +1,114 @@
/*
* 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.designsystem.components.async
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
private val DefaultShowDelay = 300.milliseconds
private val DefaultMinDisplayDuration = 500.milliseconds
private val DefaultFailureDuration = 500.milliseconds
/**
* An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing.
*
* While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation
* runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This
* prevents the indicator from flashing for operations that complete almost instantly.
*
* When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are
* never debounced) and auto-dismisses after [failureDuration].
*
* @param asyncAction the action whose progress drives the indicator.
* @param loading the composable to display while loading.
* @param failure the composable to display on failure.
* @param modifier the modifier to apply to the underlying [AsyncIndicatorHost].
* @param state the [AsyncIndicatorState] backing the host.
* @param showDelay how long the loading state must persist before the indicator is shown.
* @param minDisplayDuration the minimum duration the loading indicator stays visible once shown.
* @param failureDuration how long the failure indicator stays visible before auto-dismissing.
*/
@Composable
fun AsyncActionIndicator(
asyncAction: AsyncAction<*>,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
modifier: Modifier = Modifier,
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
showDelay: Duration = DefaultShowDelay,
minDisplayDuration: Duration = DefaultMinDisplayDuration,
failureDuration: Duration = DefaultFailureDuration,
) {
AsyncIndicatorHost(modifier = modifier, state = state)
AsyncActionIndicatorEffect(
asyncAction = asyncAction,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplayDuration,
failureDuration = failureDuration,
loading = loading,
failure = failure,
)
}
@Composable
internal fun AsyncActionIndicatorEffect(
asyncAction: AsyncAction<*>,
state: AsyncIndicatorState,
showDelay: Duration,
minDisplayDuration: Duration,
failureDuration: Duration,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
timeSource: TimeSource = TimeSource.Monotonic,
) {
// Referenced inside the restarting LaunchedEffect below, so keep them up to date.
val currentLoading by rememberUpdatedState(loading)
val currentFailure by rememberUpdatedState(failure)
// Mark set when the loader actually becomes visible, null while it is hidden.
var loaderShownAt by remember { mutableStateOf<TimeMark?>(null) }
LaunchedEffect(asyncAction) {
when (asyncAction) {
is AsyncAction.Loading -> {
// If the operation completes before this delay elapses, the effect is cancelled
// and the loader is never shown.
delay(showDelay)
state.enqueue(composable = currentLoading)
loaderShownAt = timeSource.markNow()
}
is AsyncAction.Failure -> {
// Always surface failures immediately, even if the loader was never shown.
val error = asyncAction.error
state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { currentFailure(error) })
loaderShownAt = null
}
is AsyncAction.Success -> {
// Hold the loader for its minimum duration before clearing it.
loaderShownAt?.let { shownAt ->
val remaining = minDisplayDuration - shownAt.elapsedNow()
if (remaining > Duration.ZERO) delay(remaining)
}
state.clear()
loaderShownAt = null
}
else -> Unit
}
}
}
@@ -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.libraries.designsystem.component.async
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
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.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TestTimeSource
import kotlin.time.TimeSource
@OptIn(ExperimentalCoroutinesApi::class)
class AsyncActionIndicatorTest {
private val showDelay = 300.milliseconds
private val minDisplay = 500.milliseconds
@Test
fun `loader is not shown before the show delay elapses`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
// Once the show delay has fully elapsed, the loader is shown.
advanceTimeBy(300.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader is never shown when the operation completes before the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
action.value = AsyncAction.Success(Unit)
advanceTimeBy(1000.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `failure is shown immediately, without waiting for the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Failure(Exception("failed"))
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader stays visible for the minimum duration once shown`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
val timeSource = TestTimeSource()
indicatorFlow(action, state, timeSource).test {
// Show the loader.
action.value = AsyncAction.Loading
advanceTimeBy(400.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// 200ms of display has already elapsed when the operation succeeds.
timeSource += 200.milliseconds
action.value = AsyncAction.Success(Unit)
runCurrent()
// Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// Past the minimum display duration -> cleared.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun indicatorFlow(
action: State<AsyncAction<*>>,
state: AsyncIndicatorState,
timeSource: TimeSource = TimeSource.Monotonic,
): Flow<Boolean> = moleculeFlow(RecompositionMode.Immediate) {
AsyncActionIndicatorEffect(
asyncAction = action.value,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplay,
failureDuration = minDisplay,
loading = {},
failure = {},
timeSource = timeSource,
)
state.currentAnimationState.targetState
}
}
@@ -99,6 +99,12 @@ interface MatrixClient {
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
suspend fun clearUserStatus(): Result<Unit>
/** Whether the homeserver advertises support for user status (MSC4426). */
suspend fun isUserStatusSupported(): Result<Boolean>
/** Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. */
fun enableAutomaticCallStatus(enabled: Boolean)
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?>
@@ -104,4 +104,5 @@ fun RoomMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName.takeUnless { membership == RoomMembershipState.BAN },
avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN },
displayedStatus = displayedStatus,
)
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
@Immutable
sealed interface ProfileDetails {
@@ -60,3 +61,20 @@ fun ProfileDetails.getAvatarUrl(): String? {
else -> null
}
}
fun ProfileDetails.toMatrixUser(userId: UserId): MatrixUser {
return when (this) {
is ProfileDetails.Ready -> MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
displayedStatus = displayedStatus,
)
else -> MatrixUser(
userId = userId,
displayName = null,
avatarUrl = null,
displayedStatus = null,
)
}
}
@@ -13,6 +13,9 @@ import kotlinx.parcelize.Parcelize
@Immutable
sealed interface DisplayedStatus : Parcelable {
/** Useful for adding extension methods. */
companion object
/** Status set manually by the user via m.status. */
@Parcelize
data class UserSet(val status: UserStatus) : DisplayedStatus
@@ -21,6 +24,6 @@ sealed interface DisplayedStatus : Parcelable {
@Parcelize
data class InCall(
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
val callJoinedTs: Long,
val callJoinedTs: Long?,
) : DisplayedStatus
}
@@ -49,7 +49,6 @@ 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
@@ -58,7 +57,6 @@ import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
@@ -85,7 +83,9 @@ import io.element.android.libraries.matrix.impl.search.RustMessageSearchService
import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.user.into
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
@@ -126,9 +126,11 @@ import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.UserProfile
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
@@ -271,8 +273,6 @@ class RustMatrixClient(
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
@Volatile private var localUserStatus: UserStatus? = null
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser(
userId = sessionId,
@@ -283,6 +283,14 @@ class RustMatrixClient(
override val userProfile: StateFlow<MatrixUser> = _userProfile
private var ownProfileTaskHandle: TaskHandle? = null
private val ownProfileListener = object : ProfileListener {
override fun onUpdate(profile: UserProfile) {
_userProfile.tryEmit(profile.map())
}
}
override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> {
// Fetch the initial value manually, the SDK won't return it automatically
channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList())
@@ -303,8 +311,21 @@ class RustMatrixClient(
sessionCoroutineScope.launch {
// Start notification settings
notificationSettingsService.start()
// Setup user profile logic
setupUserProfile()
}
// Update the user profile in the session store if needed
// Schedule regular database vacuuming to ensure DB performance remains optimal
scheduleDatabaseVacuum()
}
private suspend fun setupUserProfile() {
val supported = isUserStatusSupported().getOrDefault(false)
if (supported) {
// No need to seed the data here, it's already stored by the sdk.
ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener)
} else {
// Seed from the session store so cold-start UI has something to render before the network fetch.
sessionStore.getSession(sessionId.value)?.let { sessionData ->
_userProfile.emit(
MatrixUser(
@@ -314,12 +335,8 @@ class RustMatrixClient(
)
)
}
// Force a refresh of the profile
getUserProfile()
}
// Schedule regular database vacuuming to ensure DB performance remains optimal
scheduleDatabaseVacuum()
}
override fun userIdServerName(): String {
@@ -458,13 +475,8 @@ class RustMatrixClient(
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { matrixUser ->
_userProfile.emit(
matrixUser.copy(
rawStatus = localUserStatus,
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
)
)
// Also update our session storage
_userProfile.emit(matrixUser)
sessionStore.updateUserProfile(
sessionId = sessionId.value,
displayName = matrixUser.displayName,
@@ -495,25 +507,31 @@ class RustMatrixClient(
}
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)
runCatchingExceptions {
innerClient.setUserStatus(status.into())
}.onSuccess {
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
if (ownProfileTaskHandle == null) getUserProfile()
}
}
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = null
_userProfile.emit(
_userProfile.value.copy(
rawStatus = null,
displayedStatus = null,
)
)
Result.success(Unit)
runCatchingExceptions {
innerClient.clearUserStatus()
}.onSuccess {
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
if (ownProfileTaskHandle == null) getUserProfile()
}
}
override suspend fun isUserStatusSupported(): Result<Boolean> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isUserStatusSupported()
}
}
override fun enableAutomaticCallStatus(enabled: Boolean) {
innerClient.enableAutomaticCallStatus(enabled)
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
@@ -621,6 +639,7 @@ class RustMatrixClient(
sessionCoroutineScope.cancel()
clientDelegateTaskHandle?.cancelAndDestroy()
ownProfileTaskHandle?.cancelAndDestroy()
sessionVerificationService.destroy()
sessionDelegate.clearCurrentClient()
@@ -155,6 +155,7 @@ class RustMatrixClientFactory(
val syncService = client.syncService()
.withSharePos(true)
.withOfflineMode()
.withProfilesExtension()
.finish()
return RustMatrixClient(
@@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListServ
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
@@ -16,12 +16,15 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper
import io.element.android.libraries.matrix.impl.room.tombstone.map
import io.element.android.libraries.matrix.impl.user.from
import io.element.android.libraries.matrix.impl.user.into
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.Membership
@@ -102,7 +105,9 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
fun RoomHero.map(): MatrixUser = MatrixUser(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl
avatarUrl = avatarUrl,
displayedStatus = DisplayedStatus.from(status, call),
rawStatus = status?.into(),
)
fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels {
@@ -11,7 +11,9 @@ package io.element.android.libraries.matrix.impl.room.member
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.impl.room.powerlevels.into
import io.element.android.libraries.matrix.impl.user.from
import uniffi.matrix_sdk.RoomMemberRole
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
@@ -30,8 +32,7 @@ object RoomMemberMapper {
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
membershipChangeReason = roomMember.membershipChangeReason,
isServiceMember = roomMember.isServiceMember,
// TODO map this value when available
displayedStatus = null,
displayedStatus = DisplayedStatus.from(roomMember.status, roomMember.call),
)
}
@@ -21,7 +21,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetail
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.impl.core.RustSendHandle
import io.element.android.libraries.matrix.impl.user.from
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -75,8 +77,7 @@ fun RustProfileDetails.map(): ProfileDetails {
displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl,
// TODO map this value when available
displayedStatus = null,
displayedStatus = DisplayedStatus.from(status, call),
)
}
}
@@ -6,9 +6,10 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.mapper
package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import org.matrix.rustcomponents.sdk.UserProfile
@@ -16,4 +17,6 @@ fun UserProfile.map() = MatrixUser(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl,
rawStatus = status?.into(),
displayedStatus = DisplayedStatus.from(status, call)
)
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.usersearch
package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.impl.mapper.map
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.SearchUsersResults
@@ -0,0 +1,29 @@
/*
* 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.impl.user
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import org.matrix.rustcomponents.sdk.UserCall
import org.matrix.rustcomponents.sdk.UserStatus as RustUserStatus
fun UserStatus.into(): RustUserStatus {
return RustUserStatus(emoji = emoji, text = text)
}
fun RustUserStatus.into(): UserStatus {
return UserStatus(emoji = emoji, text = text)
}
fun DisplayedStatus.Companion.from(userStatus: RustUserStatus?, userCall: UserCall?): DisplayedStatus? {
return when {
userStatus != null -> DisplayedStatus.UserSet(userStatus.into())
userCall != null -> DisplayedStatus.InCall(userCall.callJoinedTs?.toLong())
else -> null
}
}
@@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.user.map
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
@@ -32,9 +32,9 @@ import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
@@ -72,7 +72,8 @@ class RustMatrixClientTest {
@Test
fun `retrieving the UserProfile updates the database`() = runTest {
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> }
val profilePersisted = CompletableDeferred<Unit>()
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> profilePersisted.complete(Unit) }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
@@ -97,7 +98,7 @@ class RustMatrixClientTest {
),
sessionStore = sessionStore,
)
advanceUntilIdle()
profilePersisted.await()
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
@@ -26,6 +26,7 @@ import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.PusherIdentifiers
import org.matrix.rustcomponents.sdk.PusherKind
import org.matrix.rustcomponents.sdk.RoomDirectorySearch
@@ -55,6 +56,8 @@ class FakeFfiClient(
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
private val isUserStatusSupportedResult: () -> Boolean = { false },
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
override fun userId(): String = userId
@@ -92,6 +95,13 @@ class FakeFfiClient(
return FakeFfiTaskHandle()
}
override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult()
override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle {
subscribeToOwnProfileResult(listener)
return FakeFfiTaskHandle()
}
override suspend fun getProfile(userId: String): UserProfile {
return getProfileResult(userId)
}
@@ -15,5 +15,6 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) {
override fun withOfflineMode(): SyncServiceBuilder = this
override fun withSharePos(enable: Boolean): SyncServiceBuilder = this
override fun withProfilesExtension(): SyncServiceBuilder = this
override suspend fun finish(): SyncService = FakeFfiSyncService()
}
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.mapper
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.test.A_USER_ID
import org.junit.Test
@@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSearchUsersResults
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
import io.element.android.libraries.matrix.test.A_USER_ID
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
@@ -130,6 +130,7 @@ class FakeMatrixClient(
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
override val contentScanner: ContentScanner? = null,
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
@@ -144,6 +145,7 @@ class FakeMatrixClient(
private set
var setUserStatusResult: Result<Unit> = Result.success(Unit)
var clearUserStatusResult: Result<Unit> = Result.success(Unit)
var isUserStatusSupportedResult: Result<Boolean> = Result.success(true)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
override val userProfile: StateFlow<MatrixUser> = _userProfile
@@ -288,6 +290,10 @@ class FakeMatrixClient(
return clearUserStatusResult
}
override suspend fun isUserStatusSupported(): Result<Boolean> = isUserStatusSupportedResult
override fun enableAutomaticCallStatus(enabled: Boolean) = enableAutomaticCallStatusLambda(enabled)
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
@@ -98,6 +98,7 @@ class KonsistPreviewTest {
"HomeTopBarSpaceFiltersSelectedPreview",
"HomeTopBarSpacesPreview",
"HomeTopBarWithIndicatorPreview",
"HomeTopBarWithStatusPreview",
"IconsOtherPreview",
"MarkdownTextComposerEditPreview",
"MatrixBadgeAtomInfoPreview",

Some files were not shown because too many files have changed in this diff Show More