mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Merge pull request #7328 from element-hq/feature/fga/user_status_last_pieces
User status last pieces
This commit is contained in:
@@ -60,6 +60,7 @@ dependencies {
|
|||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs)
|
||||||
testImplementation(projects.features.login.test)
|
testImplementation(projects.features.login.test)
|
||||||
testImplementation(projects.features.share.test)
|
testImplementation(projects.features.share.test)
|
||||||
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.oauth.test)
|
testImplementation(projects.libraries.oauth.test)
|
||||||
testImplementation(projects.libraries.permissions.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.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
|
||||||
@@ -65,6 +67,7 @@ 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 =
|
||||||
@@ -120,6 +123,16 @@ class LoggedInPresenter(
|
|||||||
}.launchIn(this)
|
}.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()
|
val networkConnectivity by networkMonitor.connectivity.collectAsState()
|
||||||
LaunchedEffect(networkConnectivity) {
|
LaunchedEffect(networkConnectivity) {
|
||||||
if (networkConnectivity == NetworkStatus.Connected) {
|
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.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
|
||||||
@@ -48,6 +51,7 @@ import io.element.android.services.analytics.api.AnalyticsService
|
|||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
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.lambdaError
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
@@ -127,6 +131,7 @@ 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)
|
||||||
@@ -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 {
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
return awaitItem()
|
return awaitItem()
|
||||||
@@ -362,6 +418,7 @@ 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,
|
||||||
@@ -373,6 +430,7 @@ class LoggedInPresenterTest {
|
|||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
networkMonitor = networkMonitor,
|
networkMonitor = networkMonitor,
|
||||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
|
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-4
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
package io.element.android.features.home.impl.components
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.RowScope
|
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.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.pager.VerticalPager
|
import androidx.compose.foundation.pager.VerticalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.material3.ripple
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
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.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
import androidx.compose.ui.graphics.Color
|
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.res.stringResource
|
||||||
import androidx.compose.ui.semantics.heading
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.semantics.semantics
|
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 androidx.compose.ui.unit.dp
|
||||||
import io.element.android.appconfig.RoomListConfig
|
import io.element.android.appconfig.RoomListConfig
|
||||||
import io.element.android.compound.theme.ElementTheme
|
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.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
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.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.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.aMatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
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.getAvatarData
|
||||||
|
import io.element.android.libraries.matrix.ui.model.toEmojiText
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@@ -151,7 +171,7 @@ fun HomeTopBar(
|
|||||||
},
|
},
|
||||||
// We want a 16dp left padding for the navigationIcon :
|
// We want a 16dp left padding for the navigationIcon :
|
||||||
// 4dp from default TopAppBarHorizontalPadding
|
// 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
|
// 4dp extra padding using left insets
|
||||||
windowInsets = WindowInsets(left = 4.dp),
|
windowInsets = WindowInsets(left = 4.dp),
|
||||||
)
|
)
|
||||||
@@ -316,19 +336,36 @@ private fun AccountIcon(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
|
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
|
||||||
IconButton(
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
modifier = modifier.then(testTag),
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.then(testTag)
|
||||||
|
.minimumInteractiveComponentSize()
|
||||||
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
indication = ripple(bounded = false),
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box {
|
|
||||||
val avatarData by remember(matrixUser) {
|
val avatarData by remember(matrixUser) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
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(
|
Avatar(
|
||||||
avatarData = avatarData,
|
avatarData = avatarData,
|
||||||
avatarType = AvatarType.User,
|
avatarType = AvatarType.User,
|
||||||
|
modifier = avatarModifier,
|
||||||
contentDescription = if (isCurrentAccount) {
|
contentDescription = if (isCurrentAccount) {
|
||||||
if (showAvatarIndicator) {
|
if (showAvatarIndicator) {
|
||||||
stringResource(CommonStrings.a11y_settings_with_required_action)
|
stringResource(CommonStrings.a11y_settings_with_required_action)
|
||||||
@@ -339,6 +376,12 @@ private fun AccountIcon(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (statusEmoji != null) {
|
||||||
|
StatusEmojiBadge(
|
||||||
|
emoji = statusEmoji,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showAvatarIndicator) {
|
if (showAvatarIndicator) {
|
||||||
RedIndicatorAtom(
|
RedIndicatorAtom(
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
@@ -346,6 +389,58 @@ private fun AccountIcon(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+2
-5
@@ -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.getDisambiguatedDisplayName
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
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.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.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||||
@@ -207,11 +208,7 @@ fun TimelineItemEventRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onUserDataClick() {
|
fun onUserDataClick() {
|
||||||
val sender = MatrixUser(
|
val sender = event.senderProfile.toMatrixUser(event.senderId)
|
||||||
userId = event.senderId,
|
|
||||||
displayName = event.senderProfile.getDisplayName(),
|
|
||||||
avatarUrl = event.senderProfile.getAvatarUrl(),
|
|
||||||
)
|
|
||||||
onUserDataClick(sender)
|
onUserDataClick(sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -74,8 +74,14 @@ class PreferencesRootPresenter(
|
|||||||
val showLinkNewDevice by remember {
|
val showLinkNewDevice by remember {
|
||||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
|
||||||
}.collectAsState(initial = false)
|
}.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 {
|
val otherSessions by remember {
|
||||||
sessionStore.sessionsFlow().map { list ->
|
sessionStore.sessionsFlow().map { list ->
|
||||||
|
|||||||
+23
-1
@@ -9,9 +9,12 @@
|
|||||||
package io.element.android.features.preferences.impl.root
|
package io.element.android.features.preferences.impl.root
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
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.ColumnScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
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
|
||||||
@@ -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.user.UserPreferences
|
||||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||||
import io.element.android.features.preferences.impl.userstatus.UserStatusView
|
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.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.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||||
@@ -71,9 +77,9 @@ fun PreferencesRootView(
|
|||||||
) {
|
) {
|
||||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
// Include pref from other modules
|
// Include pref from other modules
|
||||||
PreferencePage(
|
PreferencePage(
|
||||||
modifier = modifier,
|
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
title = stringResource(id = CommonStrings.common_settings),
|
title = stringResource(id = CommonStrings.common_settings),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
@@ -134,6 +140,22 @@ fun PreferencesRootView(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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
|
@Composable
|
||||||
|
|||||||
+10
-2
@@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Inject
|
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.Presenter
|
||||||
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
|
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
|
||||||
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
|
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
@@ -38,6 +40,7 @@ class UserStatusPresenter(
|
|||||||
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
||||||
val customTextFieldState = rememberTextFieldState()
|
val customTextFieldState = rememberTextFieldState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val updateStatusAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||||
|
|
||||||
fun handleEvent(event: UserStatusEvent) {
|
fun handleEvent(event: UserStatusEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
@@ -47,11 +50,15 @@ class UserStatusPresenter(
|
|||||||
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
|
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
|
||||||
is UserStatusEvent.SetStatus -> {
|
is UserStatusEvent.SetStatus -> {
|
||||||
pickerState = UserStatusPickerState.Hidden
|
pickerState = UserStatusPickerState.Hidden
|
||||||
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
|
coroutineScope.launch {
|
||||||
|
updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UserStatusEvent.ClearStatus -> {
|
UserStatusEvent.ClearStatus -> {
|
||||||
pickerState = UserStatusPickerState.Hidden
|
pickerState = UserStatusPickerState.Hidden
|
||||||
coroutineScope.launch { matrixClient.clearUserStatus() }
|
coroutineScope.launch {
|
||||||
|
updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UserStatusEvent.OpenCustomInput -> {
|
UserStatusEvent.OpenCustomInput -> {
|
||||||
val raw = userProfile.rawStatus
|
val raw = userProfile.rawStatus
|
||||||
@@ -93,6 +100,7 @@ class UserStatusPresenter(
|
|||||||
displayedStatus = userProfile.displayedStatus,
|
displayedStatus = userProfile.displayedStatus,
|
||||||
rawStatus = userProfile.rawStatus,
|
rawStatus = userProfile.rawStatus,
|
||||||
pickerState = effectivePickerState,
|
pickerState = effectivePickerState,
|
||||||
|
updateStatusAction = updateStatusAction.value,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.preferences.impl.R
|
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.emoji.api.picker.EmojiPickerState
|
||||||
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.api.user.UserStatus
|
||||||
@@ -19,6 +20,7 @@ data class UserStatusState(
|
|||||||
val displayedStatus: DisplayedStatus?,
|
val displayedStatus: DisplayedStatus?,
|
||||||
val rawStatus: UserStatus?,
|
val rawStatus: UserStatus?,
|
||||||
val pickerState: UserStatusPickerState,
|
val pickerState: UserStatusPickerState,
|
||||||
|
val updateStatusAction: AsyncAction<Unit>,
|
||||||
val eventSink: (UserStatusEvent) -> Unit,
|
val eventSink: (UserStatusEvent) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
@@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus
|
|||||||
|
|
||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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.DisplayedStatus
|
||||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||||
|
|
||||||
@@ -44,10 +45,12 @@ fun aUserStatusState(
|
|||||||
displayedStatus: DisplayedStatus? = null,
|
displayedStatus: DisplayedStatus? = null,
|
||||||
rawStatus: UserStatus? = null,
|
rawStatus: UserStatus? = null,
|
||||||
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
|
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
|
||||||
|
updateStatusAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (UserStatusEvent) -> Unit = {},
|
eventSink: (UserStatusEvent) -> Unit = {},
|
||||||
) = UserStatusState(
|
) = UserStatusState(
|
||||||
displayedStatus = displayedStatus,
|
displayedStatus = displayedStatus,
|
||||||
rawStatus = rawStatus,
|
rawStatus = rawStatus,
|
||||||
pickerState = pickerState,
|
pickerState = pickerState,
|
||||||
|
updateStatusAction = updateStatusAction,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|||||||
+39
@@ -8,9 +8,11 @@
|
|||||||
package io.element.android.features.preferences.impl.userstatus
|
package io.element.android.features.preferences.impl.userstatus
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.emoji.test.fakeEmojiPickerPresenterFactory
|
||||||
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.api.user.UserStatus
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -61,6 +63,24 @@ class UserStatusPresenterTest {
|
|||||||
val newState = awaitItem()
|
val newState = awaitItem()
|
||||||
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||||
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
|
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()
|
assertThat(client.setUserStatusCalled).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +141,25 @@ class UserStatusPresenterTest {
|
|||||||
val clearedState = awaitItem()
|
val clearedState = awaitItem()
|
||||||
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||||
assertThat(clearedState.displayedStatus).isNull()
|
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()
|
assertThat(client.clearUserStatusCalled).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+114
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -99,6 +99,12 @@ interface MatrixClient {
|
|||||||
|
|
||||||
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
|
/** 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 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 joinRoom(roomId: RoomId): Result<RoomInfo?>
|
||||||
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
|
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
|
||||||
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
|
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
|
||||||
|
|||||||
+1
@@ -104,4 +104,5 @@ fun RoomMember.toMatrixUser() = MatrixUser(
|
|||||||
userId = userId,
|
userId = userId,
|
||||||
displayName = displayName.takeUnless { membership == RoomMembershipState.BAN },
|
displayName = displayName.takeUnless { membership == RoomMembershipState.BAN },
|
||||||
avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN },
|
avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN },
|
||||||
|
displayedStatus = displayedStatus,
|
||||||
)
|
)
|
||||||
|
|||||||
+18
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
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.user.DisplayedStatus
|
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface ProfileDetails {
|
sealed interface ProfileDetails {
|
||||||
@@ -60,3 +61,20 @@ fun ProfileDetails.getAvatarUrl(): String? {
|
|||||||
else -> null
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -13,6 +13,9 @@ import kotlinx.parcelize.Parcelize
|
|||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface DisplayedStatus : Parcelable {
|
sealed interface DisplayedStatus : Parcelable {
|
||||||
|
/** Useful for adding extension methods. */
|
||||||
|
companion object
|
||||||
|
|
||||||
/** Status set manually by the user via m.status. */
|
/** Status set manually by the user via m.status. */
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class UserSet(val status: UserStatus) : DisplayedStatus
|
data class UserSet(val status: UserStatus) : DisplayedStatus
|
||||||
@@ -21,6 +24,6 @@ sealed interface DisplayedStatus : Parcelable {
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
data class InCall(
|
data class InCall(
|
||||||
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
|
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
|
||||||
val callJoinedTs: Long,
|
val callJoinedTs: Long?,
|
||||||
) : DisplayedStatus
|
) : DisplayedStatus
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-32
@@ -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.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
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.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.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
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.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.RustLinkDesktopHandler
|
||||||
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler
|
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.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.RustMediaLoader
|
||||||
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
|
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
|
||||||
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
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.spaces.RustSpaceService
|
||||||
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
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.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.cancelAndDestroy
|
||||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||||
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
|
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.Membership
|
||||||
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
|
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
|
||||||
import org.matrix.rustcomponents.sdk.PowerLevels
|
import org.matrix.rustcomponents.sdk.PowerLevels
|
||||||
|
import org.matrix.rustcomponents.sdk.ProfileListener
|
||||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||||
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
|
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
|
||||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||||
|
import org.matrix.rustcomponents.sdk.UserProfile
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -271,8 +273,6 @@ class RustMatrixClient(
|
|||||||
|
|
||||||
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
||||||
|
|
||||||
@Volatile private var localUserStatus: UserStatus? = null
|
|
||||||
|
|
||||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
||||||
MatrixUser(
|
MatrixUser(
|
||||||
userId = sessionId,
|
userId = sessionId,
|
||||||
@@ -283,6 +283,14 @@ class RustMatrixClient(
|
|||||||
|
|
||||||
override val userProfile: StateFlow<MatrixUser> = _userProfile
|
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>> {
|
override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> {
|
||||||
// Fetch the initial value manually, the SDK won't return it automatically
|
// Fetch the initial value manually, the SDK won't return it automatically
|
||||||
channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList())
|
channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList())
|
||||||
@@ -303,8 +311,21 @@ class RustMatrixClient(
|
|||||||
sessionCoroutineScope.launch {
|
sessionCoroutineScope.launch {
|
||||||
// Start notification settings
|
// Start notification settings
|
||||||
notificationSettingsService.start()
|
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 ->
|
sessionStore.getSession(sessionId.value)?.let { sessionData ->
|
||||||
_userProfile.emit(
|
_userProfile.emit(
|
||||||
MatrixUser(
|
MatrixUser(
|
||||||
@@ -314,12 +335,8 @@ class RustMatrixClient(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Force a refresh of the profile
|
|
||||||
getUserProfile()
|
getUserProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule regular database vacuuming to ensure DB performance remains optimal
|
|
||||||
scheduleDatabaseVacuum()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun userIdServerName(): String {
|
override fun userIdServerName(): String {
|
||||||
@@ -458,13 +475,8 @@ class RustMatrixClient(
|
|||||||
|
|
||||||
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
|
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
|
||||||
.onSuccess { matrixUser ->
|
.onSuccess { matrixUser ->
|
||||||
_userProfile.emit(
|
|
||||||
matrixUser.copy(
|
|
||||||
rawStatus = localUserStatus,
|
|
||||||
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// Also update our session storage
|
// Also update our session storage
|
||||||
|
_userProfile.emit(matrixUser)
|
||||||
sessionStore.updateUserProfile(
|
sessionStore.updateUserProfile(
|
||||||
sessionId = sessionId.value,
|
sessionId = sessionId.value,
|
||||||
displayName = matrixUser.displayName,
|
displayName = matrixUser.displayName,
|
||||||
@@ -495,25 +507,31 @@ class RustMatrixClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) {
|
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
localUserStatus = status
|
runCatchingExceptions {
|
||||||
_userProfile.emit(
|
innerClient.setUserStatus(status.into())
|
||||||
_userProfile.value.copy(
|
}.onSuccess {
|
||||||
rawStatus = status,
|
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
|
||||||
displayedStatus = DisplayedStatus.UserSet(status),
|
if (ownProfileTaskHandle == null) getUserProfile()
|
||||||
)
|
}
|
||||||
)
|
|
||||||
Result.success(Unit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
|
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
localUserStatus = null
|
runCatchingExceptions {
|
||||||
_userProfile.emit(
|
innerClient.clearUserStatus()
|
||||||
_userProfile.value.copy(
|
}.onSuccess {
|
||||||
rawStatus = null,
|
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
|
||||||
displayedStatus = null,
|
if (ownProfileTaskHandle == null) getUserProfile()
|
||||||
)
|
}
|
||||||
)
|
}
|
||||||
Result.success(Unit)
|
|
||||||
|
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) {
|
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
|
||||||
@@ -621,6 +639,7 @@ class RustMatrixClient(
|
|||||||
|
|
||||||
sessionCoroutineScope.cancel()
|
sessionCoroutineScope.cancel()
|
||||||
clientDelegateTaskHandle?.cancelAndDestroy()
|
clientDelegateTaskHandle?.cancelAndDestroy()
|
||||||
|
ownProfileTaskHandle?.cancelAndDestroy()
|
||||||
sessionVerificationService.destroy()
|
sessionVerificationService.destroy()
|
||||||
|
|
||||||
sessionDelegate.clearCurrentClient()
|
sessionDelegate.clearCurrentClient()
|
||||||
|
|||||||
+1
@@ -155,6 +155,7 @@ class RustMatrixClientFactory(
|
|||||||
val syncService = client.syncService()
|
val syncService = client.syncService()
|
||||||
.withSharePos(true)
|
.withSharePos(true)
|
||||||
.withOfflineMode()
|
.withOfflineMode()
|
||||||
|
.withProfilesExtension()
|
||||||
.finish()
|
.finish()
|
||||||
|
|
||||||
return RustMatrixClient(
|
return RustMatrixClient(
|
||||||
|
|||||||
+1
@@ -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.roomdirectory.map
|
||||||
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
|
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.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.MessageEventContent
|
||||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||||
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
||||||
|
|||||||
+6
-1
@@ -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.RoomInfo
|
||||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
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.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.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.impl.room.history.map
|
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.join.map
|
||||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
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.powerlevels.RoomPowerLevelsValuesMapper
|
||||||
import io.element.android.libraries.matrix.impl.room.tombstone.map
|
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.toImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
import org.matrix.rustcomponents.sdk.Membership
|
import org.matrix.rustcomponents.sdk.Membership
|
||||||
@@ -102,7 +105,9 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
|
|||||||
fun RoomHero.map(): MatrixUser = MatrixUser(
|
fun RoomHero.map(): MatrixUser = MatrixUser(
|
||||||
userId = UserId(userId),
|
userId = UserId(userId),
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
avatarUrl = avatarUrl
|
avatarUrl = avatarUrl,
|
||||||
|
displayedStatus = DisplayedStatus.from(status, call),
|
||||||
|
rawStatus = status?.into(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels {
|
fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels {
|
||||||
|
|||||||
+3
-2
@@ -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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
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.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.room.powerlevels.into
|
||||||
|
import io.element.android.libraries.matrix.impl.user.from
|
||||||
import uniffi.matrix_sdk.RoomMemberRole
|
import uniffi.matrix_sdk.RoomMemberRole
|
||||||
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
|
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
|
||||||
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
|
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
|
||||||
@@ -30,8 +32,7 @@ object RoomMemberMapper {
|
|||||||
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
|
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
|
||||||
membershipChangeReason = roomMember.membershipChangeReason,
|
membershipChangeReason = roomMember.membershipChangeReason,
|
||||||
isServiceMember = roomMember.isServiceMember,
|
isServiceMember = roomMember.isServiceMember,
|
||||||
// TODO map this value when available
|
displayedStatus = DisplayedStatus.from(roomMember.status, roomMember.call),
|
||||||
displayedStatus = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -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.ReactionSender
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
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.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.core.RustSendHandle
|
||||||
|
import io.element.android.libraries.matrix.impl.user.from
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
@@ -75,8 +77,7 @@ fun RustProfileDetails.map(): ProfileDetails {
|
|||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
displayNameAmbiguous = displayNameAmbiguous,
|
displayNameAmbiguous = displayNameAmbiguous,
|
||||||
avatarUrl = avatarUrl,
|
avatarUrl = avatarUrl,
|
||||||
// TODO map this value when available
|
displayedStatus = DisplayedStatus.from(status, call),
|
||||||
displayedStatus = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -6,9 +6,10 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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.core.UserId
|
||||||
|
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.MatrixUser
|
||||||
import org.matrix.rustcomponents.sdk.UserProfile
|
import org.matrix.rustcomponents.sdk.UserProfile
|
||||||
|
|
||||||
@@ -16,4 +17,6 @@ fun UserProfile.map() = MatrixUser(
|
|||||||
userId = UserId(userId),
|
userId = UserId(userId),
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
avatarUrl = avatarUrl,
|
avatarUrl = avatarUrl,
|
||||||
|
rawStatus = status?.into(),
|
||||||
|
displayedStatus = DisplayedStatus.from(status, call)
|
||||||
)
|
)
|
||||||
+1
-2
@@ -6,10 +6,9 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.impl.mapper.map
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import org.matrix.rustcomponents.sdk.SearchUsersResults
|
import org.matrix.rustcomponents.sdk.SearchUsersResults
|
||||||
|
|
||||||
+29
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
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
|
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
|
||||||
|
|
||||||
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
|
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
|
||||||
|
|||||||
+4
-3
@@ -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.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
@@ -72,7 +72,8 @@ class RustMatrixClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `retrieving the UserProfile updates the database`() = runTest {
|
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(
|
val sessionStore = InMemorySessionStore(
|
||||||
initialList = listOf(
|
initialList = listOf(
|
||||||
aSessionData(
|
aSessionData(
|
||||||
@@ -97,7 +98,7 @@ class RustMatrixClientTest {
|
|||||||
),
|
),
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
)
|
)
|
||||||
advanceUntilIdle()
|
profilePersisted.await()
|
||||||
updateUserProfileResult.assertions().isCalledOnce()
|
updateUserProfileResult.assertions().isCalledOnce()
|
||||||
.with(
|
.with(
|
||||||
value(A_USER_ID.value),
|
value(A_USER_ID.value),
|
||||||
|
|||||||
+10
@@ -26,6 +26,7 @@ import org.matrix.rustcomponents.sdk.NoHandle
|
|||||||
import org.matrix.rustcomponents.sdk.NotificationClient
|
import org.matrix.rustcomponents.sdk.NotificationClient
|
||||||
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
|
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
|
||||||
import org.matrix.rustcomponents.sdk.NotificationSettings
|
import org.matrix.rustcomponents.sdk.NotificationSettings
|
||||||
|
import org.matrix.rustcomponents.sdk.ProfileListener
|
||||||
import org.matrix.rustcomponents.sdk.PusherIdentifiers
|
import org.matrix.rustcomponents.sdk.PusherIdentifiers
|
||||||
import org.matrix.rustcomponents.sdk.PusherKind
|
import org.matrix.rustcomponents.sdk.PusherKind
|
||||||
import org.matrix.rustcomponents.sdk.RoomDirectorySearch
|
import org.matrix.rustcomponents.sdk.RoomDirectorySearch
|
||||||
@@ -55,6 +56,8 @@ class FakeFfiClient(
|
|||||||
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
|
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
|
||||||
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
|
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
|
||||||
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
|
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
|
||||||
|
private val isUserStatusSupportedResult: () -> Boolean = { false },
|
||||||
|
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
|
||||||
private val closeResult: () -> Unit = {},
|
private val closeResult: () -> Unit = {},
|
||||||
) : Client(NoHandle) {
|
) : Client(NoHandle) {
|
||||||
override fun userId(): String = userId
|
override fun userId(): String = userId
|
||||||
@@ -92,6 +95,13 @@ class FakeFfiClient(
|
|||||||
return FakeFfiTaskHandle()
|
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 {
|
override suspend fun getProfile(userId: String): UserProfile {
|
||||||
return getProfileResult(userId)
|
return getProfileResult(userId)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -15,5 +15,6 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
|
|||||||
class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) {
|
class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) {
|
||||||
override fun withOfflineMode(): SyncServiceBuilder = this
|
override fun withOfflineMode(): SyncServiceBuilder = this
|
||||||
override fun withSharePos(enable: Boolean): SyncServiceBuilder = this
|
override fun withSharePos(enable: Boolean): SyncServiceBuilder = this
|
||||||
|
override fun withProfilesExtension(): SyncServiceBuilder = this
|
||||||
override suspend fun finish(): SyncService = FakeFfiSyncService()
|
override suspend fun finish(): SyncService = FakeFfiSyncService()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.mapper
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
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.fixtures.factories.aRustUserProfile
|
||||||
|
import io.element.android.libraries.matrix.impl.user.map
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSearchUsersResults
|
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.fixtures.factories.aRustUserProfile
|
||||||
|
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|||||||
+6
@@ -130,6 +130,7 @@ class FakeMatrixClient(
|
|||||||
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
|
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
|
||||||
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
|
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
|
||||||
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
|
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
|
||||||
|
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
|
||||||
override val contentScanner: ContentScanner? = null,
|
override val contentScanner: ContentScanner? = null,
|
||||||
) : MatrixClient {
|
) : MatrixClient {
|
||||||
var setDisplayNameCalled: Boolean = false
|
var setDisplayNameCalled: Boolean = false
|
||||||
@@ -144,6 +145,7 @@ class FakeMatrixClient(
|
|||||||
private set
|
private set
|
||||||
var setUserStatusResult: Result<Unit> = Result.success(Unit)
|
var setUserStatusResult: Result<Unit> = Result.success(Unit)
|
||||||
var clearUserStatusResult: 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))
|
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
|
||||||
override val userProfile: StateFlow<MatrixUser> = _userProfile
|
override val userProfile: StateFlow<MatrixUser> = _userProfile
|
||||||
@@ -288,6 +290,10 @@ class FakeMatrixClient(
|
|||||||
return clearUserStatusResult
|
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 joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
|
||||||
|
|
||||||
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
|
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class KonsistPreviewTest {
|
|||||||
"HomeTopBarSpaceFiltersSelectedPreview",
|
"HomeTopBarSpaceFiltersSelectedPreview",
|
||||||
"HomeTopBarSpacesPreview",
|
"HomeTopBarSpacesPreview",
|
||||||
"HomeTopBarWithIndicatorPreview",
|
"HomeTopBarWithIndicatorPreview",
|
||||||
|
"HomeTopBarWithStatusPreview",
|
||||||
"IconsOtherPreview",
|
"IconsOtherPreview",
|
||||||
"MarkdownTextComposerEditPreview",
|
"MarkdownTextComposerEditPreview",
|
||||||
"MatrixBadgeAtomInfoPreview",
|
"MatrixBadgeAtomInfoPreview",
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user