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:
+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?> {
|
||||
|
||||
Reference in New Issue
Block a user