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