Merge branch 'develop' into feature-oled-black

This commit is contained in:
Benoit Marty 2026-04-17 14:47:15 +02:00 committed by GitHub
commit 4e5542396f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
319 changed files with 8286 additions and 2172 deletions

View file

@ -0,0 +1,33 @@
/*
* 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.androidutils.service
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
interface ServiceBinder {
fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean
fun unbindService(conn: ServiceConnection)
}
@ContributesBinding(AppScope::class)
class DefaultServiceBinder(
@ApplicationContext private val context: Context,
) : ServiceBinder {
override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
return context.bindService(service, conn, flags)
}
override fun unbindService(conn: ServiceConnection) {
context.unbindService(conn)
}
}

View file

@ -0,0 +1,54 @@
/*
* 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.architecture.appyx
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
import com.bumble.appyx.navmodel.backstack.operation.Replace
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
/**
* A TransitionHandler that uses fade transition when the operation is Replace or NewRoot,
* and slide transition for all other cases.
*/
private class FaderOrSliderTransitionHandler<NavTarget>(
private val slider: ModifierTransitionHandler<NavTarget, BackStack.State>,
private val fader: ModifierTransitionHandler<NavTarget, BackStack.State>,
) : ModifierTransitionHandler<NavTarget, BackStack.State>() {
override fun createModifier(
modifier: Modifier,
transition: Transition<BackStack.State>,
descriptor: TransitionDescriptor<NavTarget, BackStack.State>
): Modifier {
val operation = descriptor.operation
val useFader = operation is Replace || operation is NewRoot
val handler = if (useFader) fader else slider
return handler.createModifier(modifier, transition, descriptor)
}
}
@Composable
fun <NavTarget> rememberFaderOrSliderTransitionHandler(): ModifierTransitionHandler<NavTarget, BackStack.State> {
val slider = rememberBackstackSlider<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val fader = rememberBackstackFader<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
return rememberDelegateTransitionHandler {
FaderOrSliderTransitionHandler(slider, fader)
}
}

View file

@ -13,16 +13,18 @@ import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
CurrentRoomTopBar(32.dp),
IncomingCall(140.dp),
RoomDetailsHeader(96.dp),
RoomListItem(52.dp),
ThreadsListItem(52.dp),
SpaceListItem(52.dp),
RoomSelectRoomListItem(36.dp),
UserPreference(56.dp),
UserPreference(52.dp),
UserHeader(96.dp),
UserListItem(36.dp),

View file

@ -0,0 +1,79 @@
/*
* 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.avatar
import android.graphics.Bitmap
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.libraries.designsystem.components.avatar.internal.InitialLetterAvatar
import timber.log.Timber
// For user avatar only.
@Composable
fun BitmapAvatar(
avatarData: AvatarData,
bitmap: Bitmap?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val avatarShape = AvatarType.User.avatarShape()
when {
bitmap == null -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
else -> {
val size = avatarData.size.dp
SubcomposeAsyncImage(
model = bitmap,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(avatarShape)
) {
val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.e(
state.result.throwable,
"Error loading avatar $state\n${state.result}"
)
}
InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
}
}
}
}

View file

@ -8,17 +8,22 @@
package io.element.android.libraries.designsystem.preview
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import io.element.android.compound.theme.Theme
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun ElementPreviewDark(
showBackground: Boolean = true,
content: @Composable () -> Unit
@DrawableRes
drawableFallbackForImages: Int = CommonDrawables.sample_background,
content: @Composable () -> Unit,
) {
ElementPreview(
theme = Theme.Dark,
showBackground = showBackground,
content = content
drawableFallbackForImages = drawableFallbackForImages,
content = content,
)
}

View file

@ -8,17 +8,22 @@
package io.element.android.libraries.designsystem.preview
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import io.element.android.compound.theme.Theme
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun ElementPreviewLight(
showBackground: Boolean = true,
content: @Composable () -> Unit
@DrawableRes
drawableFallbackForImages: Int = CommonDrawables.sample_background,
content: @Composable () -> Unit,
) {
ElementPreview(
theme = Theme.Light,
showBackground = showBackground,
content = content
drawableFallbackForImages = drawableFallbackForImages,
content = content,
)
}

View file

@ -0,0 +1,19 @@
/*
* 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.utils
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun hasCompactHeightWindowSize(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
}

View file

@ -162,4 +162,11 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
RoomThreadList(
key = "feature.room_thread_list",
title = "Add a list of threads in a room",
description = "Add a new screen with a list of threads in a room.",
defaultValue = { false },
isFinished = false,
),
}

View file

@ -0,0 +1,30 @@
/*
* 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.api
/**
* Provides information about the capabilities of the homeserver.
*
* Spec: https://spec.matrix.org/latest/client-server-api/#capabilities-negotiation
*/
interface HomeserverCapabilitiesProvider {
/**
* Manually refresh the capabilities of the homeserver performing a network request.
*/
suspend fun refresh(): Result<Unit>
/**
* Indicates whether the homeserver allows the user to change their display name.
*/
suspend fun canChangeDisplayName(): Result<Boolean>
/**
* Indicates whether the homeserver allows the user to change their avatar URL.
*/
suspend fun canChangeAvatarUrl(): Result<Boolean>
}

View file

@ -223,6 +223,8 @@ interface MatrixClient {
* Resets the cached client `well-known` config by the SDK.
*/
suspend fun resetWellKnownConfig(): Result<Unit>
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
}
/**

View file

@ -0,0 +1,18 @@
/*
* 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.api.auth
import io.element.android.libraries.matrix.api.core.UserId
data class ElementClassicSession(
val userId: UserId,
val homeserverUrl: String?,
val secrets: String?,
val roomKeysVersion: String?,
val doesContainBackupKey: Boolean,
)

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
interface MatrixAuthenticationService {
/**
@ -52,6 +53,20 @@ interface MatrixAuthenticationService {
*/
suspend fun cancelOidcLogin(): Result<Unit>
/**
* Set the existing data about Element Classic session, if any.
*/
fun setElementClassicSession(session: ElementClassicSession?)
/**
* Check if the provided secrets from Element Classic session contain a key backup.
*/
fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean
/**
* Attempt to login using the [callbackUrl] provided by the Oidc page.
*/

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -44,6 +45,8 @@ interface JoinedRoom : BaseRoom {
*/
val liveTimeline: Timeline
val threadsListService: ThreadsListService
/**
* Create a new timeline.
* @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators.

View file

@ -0,0 +1,34 @@
/*
* 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.api.room.threads
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
@Immutable
data class ThreadListItem(
val rootEvent: ThreadListItemEvent,
val latestEvent: ThreadListItemEvent?,
val numberOfReplies: Long,
) {
val threadId = rootEvent.eventId.toThreadId()
}
@Immutable
data class ThreadListItemEvent(
val eventId: EventId,
val senderId: UserId,
val senderProfile: ProfileDetails,
val isOwn: Boolean,
val content: EventContent?,
val timestamp: Long,
)

View file

@ -0,0 +1,16 @@
/*
* 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.api.room.threads
sealed interface ThreadListPaginationStatus {
data class Idle(
val hasMoreToLoad: Boolean,
) : ThreadListPaginationStatus
data object Loading : ThreadListPaginationStatus
}

View file

@ -0,0 +1,18 @@
/*
* 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.api.room.threads
import kotlinx.coroutines.flow.Flow
interface ThreadsListService {
fun subscribeToItemUpdates(): Flow<List<ThreadListItem>>
fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus>
suspend fun paginate(): Result<Unit>
suspend fun reset(): Result<Unit>
fun destroy()
}

View file

@ -0,0 +1,20 @@
/*
* 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.api.core
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UserIdTest {
@Test
fun `valid user id`() {
val userId = UserId("@alice:example.org")
assertThat(userId.extractedDisplayName).isEqualTo("alice")
assertThat(userId.domainName).isEqualTo("example.org")
}
}

View file

@ -0,0 +1,28 @@
/*
* 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
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
class RustHomeserverCapabilitiesProvider(
private val homeserverCapabilities: HomeserverCapabilities,
) : HomeserverCapabilitiesProvider {
override suspend fun refresh(): Result<Unit> = runCatchingExceptions {
homeserverCapabilities.refresh()
}
override suspend fun canChangeDisplayName(): Result<Boolean> = runCatchingExceptions {
homeserverCapabilities.canChangeDisplayname()
}
override suspend fun canChangeAvatarUrl(): Result<Boolean> = runCatchingExceptions {
homeserverCapabilities.canChangeAvatar()
}
}

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
@ -835,6 +836,10 @@ class RustMatrixClient(
val request = PerformDatabaseVacuumRequestBuilder(sessionId)
sessionCoroutineScope.launch { workManagerScheduler.submit(request) }
}
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
}
}
private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels(

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
@ -50,6 +52,7 @@ import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
import org.matrix.rustcomponents.sdk.SecretsBundleWithUserId
import timber.log.Timber
import uniffi.matrix_sdk.OAuthAuthorizationData
import kotlin.time.Duration.Companion.seconds
@ -64,6 +67,9 @@ class RustMatrixAuthenticationService(
private val passphraseGenerator: PassphraseGenerator,
private val oidcConfigurationProvider: OidcConfigurationProvider,
) : MatrixAuthenticationService {
// Any existing Element Classic session that we want to try to import secrets from during login.
private var elementClassicSession: ElementClassicSession? = null
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData.
private val pendingPassphrase = getDatabasePassphrase()
@ -138,9 +144,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.login(username, password, "Element X Android", null)
client.login(
username = username,
password = password,
initialDeviceName = "Element X Android",
deviceId = null,
)
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
@ -162,6 +174,53 @@ class RustMatrixAuthenticationService(
}
}
private suspend fun tryToImportSecretForElementClassicSession(client: Client) {
elementClassicSession
?.takeIf {
// Note: the SDK will also do this check
it.userId.value == client.userId()
}
?.let {
val secrets = it.secrets
val roomKeysVersion = it.roomKeysVersion
if (secrets == null || roomKeysVersion == null) {
Timber.d("No secrets or roomKeysVersion found for Element Classic session ${it.userId}, skipping import")
} else {
Timber.d("Trying to import secrets for Element Classic session ${it.userId}")
runCatchingExceptions {
SecretsBundleWithUserId.fromStr(
userId = it.userId.value,
bundle = secrets,
backupInfo = roomKeysVersion,
).use { secretsBundle ->
client.encryption().importSecretsBundle(secretsBundle)
}
}.onFailure { failure ->
Timber.e(failure, "Failed to import secrets for Element Classic session ${it.userId}")
}
}
}
}
override fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean {
return try {
SecretsBundleWithUserId.fromStr(
userId = userId.value,
bundle = secrets,
backupInfo = backupInfo,
).use { secretsBundle ->
secretsBundle.containsBackupKey()
}
} catch (failure: Exception) {
Timber.e(failure, "Failed to parse secrets for Element Classic session $userId")
false
}
}
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
withContext(coroutineDispatchers.io) {
runCatchingExceptions {
@ -233,6 +292,10 @@ class RustMatrixAuthenticationService(
}
}
override fun setElementClassicSession(session: ElementClassicSession?) {
elementClassicSession = session
}
/**
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
*/
@ -241,14 +304,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.loginWithOidcCallback(callbackUrl)
client.loginWithOidcCallback(
callbackUrl = callbackUrl,
)
// Free the pending data since we won't use it to abort the flow anymore
pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,

View file

@ -13,6 +13,7 @@ import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
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
@ -90,4 +91,9 @@ object SessionMatrixModule {
fun providesSpaceService(matrixClient: MatrixClient): SpaceService {
return matrixClient.spaceService
}
@Provides
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
return matrixClient.homeserverCapabilities()
}
}

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -44,8 +45,10 @@ 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.knock.RustKnockRequest
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
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.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
@ -145,6 +148,12 @@ class JoinedRustRoom(
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
override val threadsListService: ThreadsListService = RustThreadsListService(
inner = innerRoom.threadListService(),
contentMapper = TimelineEventContentMapper(),
roomCoroutineScope = roomCoroutineScope,
)
override val syncUpdateFlow = flow {
var counter = 0L
liveTimeline.onSyncedEventReceived.collect {
@ -528,6 +537,7 @@ class JoinedRustRoom(
override fun destroy() {
baseRoom.destroy()
liveInnerTimeline.destroy()
threadsListService.destroy()
Timber.d("Room $roomId destroyed")
}

View file

@ -0,0 +1,157 @@
/*
* 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.room.threads
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.map
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService
class RustThreadsListService(
private val inner: InnerThreadListService,
private val roomCoroutineScope: CoroutineScope,
private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(),
) : ThreadsListService {
private var itemSubscriptionJob: Job? = null
private val items = MutableStateFlow<List<ThreadListItem>>(emptyList())
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
if (itemSubscriptionJob?.isActive != true) {
itemSubscriptionJob = doSubscribeToItemUpdates()
}
return items
}
private fun doSubscribeToItemUpdates(): Job {
val updatesFlow = mxCallbackFlow {
inner.subscribeToItemsUpdates(object : ThreadListEntriesListener {
override fun onUpdate(diff: List<ThreadListUpdate>) {
trySend(diff)
}
})
}
return updatesFlow
.onStart { items.value = inner.items().map { it.map(contentMapper) } }
.onEach { diff ->
val updated = items.value.toMutableList()
updated.apply(diff, contentMapper)
items.value = updated
}
.launchIn(roomCoroutineScope)
}
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
return mxCallbackFlow {
inner.subscribeToPaginationStateUpdates(object : ThreadListPaginationStateListener {
override fun onUpdate(state: ThreadListPaginationState) {
trySend(state.map())
}
}).also {
// Send the initial state
trySend(inner.paginationState().map())
}
}
}
override suspend fun paginate(): Result<Unit> = runCatchingExceptions {
inner.paginate()
}
override suspend fun reset(): Result<Unit> = runCatchingExceptions {
inner.reset()
}
override fun destroy() {
itemSubscriptionJob?.cancel()
inner.destroy()
}
}
private fun MutableList<ThreadListItem>.apply(
diff: List<ThreadListUpdate>,
contentMapper: TimelineEventContentMapper
) {
for (diffItem in diff) {
when (diffItem) {
is ThreadListUpdate.Append -> {
val newItems = diffItem.values.map { it.map(contentMapper) }
addAll(newItems)
}
ThreadListUpdate.Clear -> clear()
is ThreadListUpdate.Insert -> {
add(diffItem.index.toInt(), diffItem.value.map(contentMapper))
}
ThreadListUpdate.PopBack -> {
removeAt(lastIndex)
}
ThreadListUpdate.PopFront -> {
removeAt(0)
}
is ThreadListUpdate.PushBack -> {
add(diffItem.value.map(contentMapper))
}
is ThreadListUpdate.PushFront -> {
add(0, diffItem.value.map(contentMapper))
}
is ThreadListUpdate.Remove -> {
removeAt(diffItem.index.toInt())
}
is ThreadListUpdate.Reset -> {
clear()
addAll(diffItem.values.map { it.map(contentMapper) })
}
is ThreadListUpdate.Set -> {
set(diffItem.index.toInt(), diffItem.value.map(contentMapper))
}
is ThreadListUpdate.Truncate -> {
subList(diffItem.length.toInt(), size).clear()
}
}
}
}
fun org.matrix.rustcomponents.sdk.ThreadListItem.map(contentMapper: TimelineEventContentMapper): ThreadListItem = ThreadListItem(
rootEvent = rootEvent.map(contentMapper),
latestEvent = latestEvent?.map(contentMapper),
numberOfReplies = numReplies.toLong(),
)
fun org.matrix.rustcomponents.sdk.ThreadListItemEvent.map(contentMapper: TimelineEventContentMapper): ThreadListItemEvent = ThreadListItemEvent(
eventId = EventId(eventId),
senderId = UserId(sender),
isOwn = isOwn,
senderProfile = senderProfile.map(),
content = content?.let(contentMapper::map),
timestamp = timestamp.toLong(),
)
fun ThreadListPaginationState.map(): ThreadListPaginationStatus = when (this) {
is ThreadListPaginationState.Idle -> ThreadListPaginationStatus.Idle(hasMoreToLoad = !endReached)
ThreadListPaginationState.Loading -> ThreadListPaginationStatus.Loading
}

View file

@ -130,6 +130,8 @@ class RustTimeline(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode is Timeline.Mode.FocusedOnEvent)
)
private val loggerTag = "Timeline($mode)"
init {
when (mode) {
is Timeline.Mode.Live, is Timeline.Mode.FocusedOnEvent -> coroutineScope.fetchMembers()
@ -177,10 +179,11 @@ class RustTimeline(
}
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
when (direction) {
val result = when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
}
Timber.tag(loggerTag).d("updatePaginationStatus $direction: $result")
}
// Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled.
@ -195,12 +198,13 @@ class RustTimeline(
}
}.onFailure { error ->
if (error is TimelineException.CannotPaginate) {
Timber.d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
Timber.tag(loggerTag).d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
} else {
updatePaginationStatus(direction) { it.copy(isPaginating = false) }
Timber.e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
Timber.tag(loggerTag).e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
}
}.onSuccess { hasReachedEnd ->
Timber.tag(loggerTag).d("Finished paginating $direction on room ${joinedRoom.roomId}, hasReachedEnd: $hasReachedEnd")
updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) }
}
}
@ -264,7 +268,7 @@ class RustTimeline(
try {
inner.fetchMembers()
} catch (exception: Exception) {
Timber.e(exception, "Error fetching members for room ${joinedRoom.roomId}")
Timber.tag(loggerTag).e(exception, "Error fetching members for room ${joinedRoom.roomId}")
}
}
@ -370,7 +374,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending image ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending image ${file.path.hash()}")
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
inner.sendImage(
params = UploadParameters(
@ -396,7 +400,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending video ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending video ${file.path.hash()}")
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
inner.sendVideo(
params = UploadParameters(
@ -421,7 +425,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending audio ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending audio ${file.path.hash()}")
return sendAttachment(listOf(file)) {
inner.sendAudio(
params = UploadParameters(
@ -445,7 +449,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending file ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending file ${file.path.hash()}")
return sendAttachment(listOf(file)) {
inner.sendFile(
params = UploadParameters(
@ -475,7 +479,7 @@ class RustTimeline(
runCatchingExceptions {
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
}.onFailure {
Timber.e(it)
Timber.tag(loggerTag).e(it)
}
}

View file

@ -0,0 +1,64 @@
/*
* 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
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverCapabilities
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RustHomeserverCapabilitiesProviderTest {
@Test
fun `refresh calls client refresh`() = runTest {
val refreshLambda = lambdaRecorder<Unit> {}
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
)
assertThat(provider.refresh().isSuccess).isTrue()
refreshLambda.assertions().isCalledOnce()
}
@Test
fun `refresh fails when client refresh does`() = runTest {
val refreshLambda = lambdaRecorder<Unit> { throw IllegalStateException("Failed to refresh capabilities") }
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
)
assertThat(provider.refresh().isFailure).isTrue()
refreshLambda.assertions().isCalledOnce()
}
@Test
fun `canChangeDisplayName returns expected value`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { true }),
)
assertThat(provider.canChangeDisplayName().getOrNull()).isTrue()
}
@Test
fun `canChangeAvatarUrl returns expected value`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeAvatar = { true }),
)
assertThat(provider.canChangeAvatarUrl().getOrNull()).isTrue()
}
@Test
fun `canChangeDisplayName returns failure when client throws`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { throw IllegalStateException("Failed to get display name capability") }),
)
assert(provider.canChangeDisplayName().isFailure)
}
private fun createCapabilitiesProvider(
capabilities: FakeFfiHomeserverCapabilities = FakeFfiHomeserverCapabilities(),
) = RustHomeserverCapabilitiesProvider(capabilities)
}

View file

@ -17,6 +17,7 @@ import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NoHandle
@ -50,6 +51,7 @@ class FakeFfiClient(
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
override fun userId(): String = userId
@ -103,5 +105,9 @@ class FakeFfiClient(
return createRoomResult(request)
}
override fun homeserverCapabilities(): HomeserverCapabilities {
return homeserverCapabilities
}
override fun close() = closeResult()
}

View file

@ -0,0 +1,33 @@
/*
* 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.fixtures.fakes
import io.element.android.tests.testutils.lambda.lambdaError
import org.matrix.rustcomponents.sdk.ExtendedProfileFields
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.NoHandle
class FakeFfiHomeserverCapabilities(
private val refresh: () -> Unit = { lambdaError() },
private val canChangeDisplayName: () -> Boolean = { lambdaError() },
private val canChangeAvatar: () -> Boolean = { lambdaError() },
private val canChangePassword: () -> Boolean = { lambdaError() },
private val canChangeThirdPartyIds: () -> Boolean = { lambdaError() },
private val canGetLoginToken: () -> Boolean = { lambdaError() },
private val forgetsRoomWhenLeaving: () -> Boolean = { lambdaError() },
private val extendedProfileFields: () -> ExtendedProfileFields = { lambdaError() },
) : HomeserverCapabilities(NoHandle) {
override suspend fun refresh() = refresh.invoke()
override suspend fun canChangeDisplayname(): Boolean = canChangeDisplayName.invoke()
override suspend fun canChangeAvatar(): Boolean = canChangeAvatar.invoke()
override suspend fun canChangePassword(): Boolean = canChangePassword.invoke()
override suspend fun canChangeThirdpartyIds(): Boolean = canChangeThirdPartyIds.invoke()
override suspend fun canGetLoginToken(): Boolean = canGetLoginToken.invoke()
override suspend fun forgetsRoomWhenLeaving(): Boolean = forgetsRoomWhenLeaving.invoke()
override suspend fun extendedProfileFields(): ExtendedProfileFields = extendedProfileFields.invoke()
}

View file

@ -0,0 +1,58 @@
/*
* 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.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListItem
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListService
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
class FakeFfiThreadListService(
private val subscribeToItemsUpdates: (ThreadListEntriesListener) -> TaskHandle = { FakeFfiTaskHandle() },
private val subscribeToPaginationStateUpdates: (ThreadListPaginationStateListener) -> TaskHandle = { FakeFfiTaskHandle() },
private val items: () -> List<ThreadListItem> = { emptyList() },
private val paginationState: () -> ThreadListPaginationState = { ThreadListPaginationState.Idle(endReached = false) },
private val paginate: suspend () -> Unit = {},
private val reset: suspend () -> Unit = {},
private val destroy: () -> Unit = {},
) : ThreadListService(NoHandle) {
private var itemsListener: ThreadListEntriesListener? = null
private var paginationStateListener: ThreadListPaginationStateListener? = null
override fun subscribeToItemsUpdates(listener: ThreadListEntriesListener): TaskHandle {
itemsListener = listener
return subscribeToItemsUpdates.invoke(listener)
}
override fun subscribeToPaginationStateUpdates(listener: ThreadListPaginationStateListener): TaskHandle {
paginationStateListener = listener
return subscribeToPaginationStateUpdates.invoke(listener)
}
override fun items(): List<ThreadListItem> = items.invoke()
override fun paginationState(): ThreadListPaginationState = paginationState.invoke()
override suspend fun paginate() = paginate.invoke()
override suspend fun reset() = reset.invoke()
override fun destroy() = destroy.invoke()
fun emitUpdates(updates: List<ThreadListUpdate>) {
itemsListener?.onUpdate(updates)
}
fun emitPaginationState(state: ThreadListPaginationState) {
paginationStateListener?.onUpdate(state)
}
}

View file

@ -0,0 +1,143 @@
/*
* 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.room.threads
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineItemContentMsgLike
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiThreadListService
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListItem
import org.matrix.rustcomponents.sdk.ThreadListItemEvent
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
@OptIn(ExperimentalCoroutinesApi::class)
class RustThreadsListServiceTest {
@Test
fun `subscribing to item updates calls the FFI method and allows retrieving new items`() = runTest {
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToItemUpdates().test {
assertThat(awaitItem()).isEmpty()
runCurrent()
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
inner.emitUpdates(listOf(aRustThreadListUpdate()))
assertThat(awaitItem()).isNotEmpty()
}
}
@Suppress("UnusedFlow")
@Test
fun `subscribing to item updates twice only calls the FFI method once`() = runTest {
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToItemUpdates()
service.subscribeToItemUpdates()
runCurrent()
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
}
@Test
fun `subscribing to pagination updates calls the FFI method and allows retrieving new items`() = runTest {
val subscribeToPaginationUpdatesRecorder = lambdaRecorder<ThreadListPaginationStateListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToPaginationStateUpdates = subscribeToPaginationUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToPaginationUpdates().test {
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Idle(hasMoreToLoad = true))
runCurrent()
subscribeToPaginationUpdatesRecorder.assertions().isCalledOnce()
inner.emitPaginationState(ThreadListPaginationState.Loading)
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Loading)
}
}
@Test
fun `paginate calls the FFI method`() = runTest {
val paginateRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(paginate = paginateRecorder)
val service = createThreadsListService(inner = inner)
service.paginate()
paginateRecorder.assertions().isCalledOnce()
}
@Test
fun `reset calls the FFI method`() = runTest {
val resetRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(reset = resetRecorder)
val service = createThreadsListService(inner = inner)
service.reset()
resetRecorder.assertions().isCalledOnce()
}
@Test
fun `destroy calls the FFI method`() = runTest {
val destroyRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(destroy = destroyRecorder)
val service = createThreadsListService(inner = inner)
service.destroy()
destroyRecorder.assertions().isCalledOnce()
}
private fun TestScope.createThreadsListService(
inner: FakeFfiThreadListService = FakeFfiThreadListService(),
) = RustThreadsListService(
inner = inner,
roomCoroutineScope = backgroundScope,
)
private fun aRustThreadListUpdate() = ThreadListUpdate.Append(
values = listOf(
ThreadListItem(
rootEvent = ThreadListItemEvent(
eventId = AN_EVENT_ID.value,
timestamp = A_TIMESTAMP.toULong(),
sender = A_USER_ID.value,
senderProfile = ProfileDetails.Pending,
isOwn = true,
content = aRustTimelineItemContentMsgLike(),
),
numReplies = 0u,
latestEvent = null,
)
),
)
}

View file

@ -0,0 +1,20 @@
/*
* 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.test
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
class FakeHomeserverCapabilitiesProvider(
private val refresh: () -> Result<Unit> = { Result.success(Unit) },
private val canChangeDisplayName: () -> Result<Boolean> = { Result.success(true) },
private val canChangeAvatarUrl: () -> Result<Boolean> = { Result.success(true) },
) : HomeserverCapabilitiesProvider {
override suspend fun refresh(): Result<Unit> = refresh.invoke()
override suspend fun canChangeDisplayName(): Result<Boolean> = canChangeDisplayName.invoke()
override suspend fun canChangeAvatarUrl(): Result<Boolean> = canChangeAvatarUrl.invoke()
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
@ -84,6 +85,7 @@ class FakeMatrixClient(
override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
Result.success(
@ -384,4 +386,8 @@ class FakeMatrixClient(
override suspend fun resetWellKnownConfig(): Result<Unit> {
return resetWellKnownConfigLambda()
}
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return homeserverCapabilitiesProvider
}
}

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -17,6 +18,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -32,6 +34,8 @@ class FakeMatrixAuthenticationService(
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
) : MatrixAuthenticationService {
private var oidcError: Throwable? = null
private var oidcCancelError: Throwable? = null
@ -108,4 +112,12 @@ class FakeMatrixAuthenticationService(
fun givenMatrixClient(matrixClient: MatrixClient) {
this.matrixClient = matrixClient
}
override fun setElementClassicSession(session: ElementClassicSession?) {
setElementClassicSessionResult(session)
}
override fun doSecretsContainBackupKey(userId: UserId, secrets: String, backupInfo: String): Boolean {
return doSecretsContainBackupKeyResult(userId, secrets, backupInfo)
}
}

View file

@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
@ -56,6 +57,7 @@ class FakeJoinedRoom(
override val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState> =
MutableStateFlow(RoomNotificationSettingsState.Unknown),
override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()),
override val threadsListService: FakeThreadsListService = FakeThreadsListService(),
private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() },
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },

View file

@ -0,0 +1,48 @@
/*
* 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.test.room.threads
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeThreadsListService(
private val items: MutableStateFlow<List<ThreadListItem>> = MutableStateFlow(emptyList()),
private val paginationStatus: MutableStateFlow<ThreadListPaginationStatus> = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)),
private val subscribeToItemUpdates: () -> Flow<List<ThreadListItem>> = { items },
private val subscribeToPaginationUpdates: () -> Flow<ThreadListPaginationStatus> = { paginationStatus },
private val paginate: suspend () -> Result<Unit> = { Result.success(Unit) },
private val reset: suspend () -> Result<Unit> = { Result.success(Unit) },
private val destroy: () -> Unit = {},
) : ThreadsListService {
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
return subscribeToItemUpdates.invoke()
}
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
return subscribeToPaginationUpdates.invoke()
}
override suspend fun paginate(): Result<Unit> {
return paginate.invoke()
}
override suspend fun reset(): Result<Unit> {
return reset.invoke()
}
override fun destroy() {
return destroy.invoke()
}
suspend fun emit(items: List<ThreadListItem>) {
this.items.emit(items)
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -22,9 +23,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
@ -33,6 +38,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.R
@ -48,10 +54,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun CreateDmConfirmationBottomSheet(
matrixUser: MatrixUser,
enableKeyShareOnInvite: Boolean,
isUserIdentityUnknown: Boolean,
onSendInvite: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val titleContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_title)
} else {
stringResource(R.string.screen_bottom_sheet_create_dm_title)
}
val descriptionContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_content)
} else {
stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName())
}
ModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
@ -63,47 +82,95 @@ fun CreateDmConfirmationBottomSheet(
.padding(top = 24.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.screen_bottom_sheet_create_dm_title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onSendInvite,
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onDismiss,
text = stringResource(CommonStrings.action_cancel),
)
if (isUserIdentityUnknown) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
),
title = titleContent,
subTitle = descriptionContent,
iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()),
)
MatrixUserRow(matrixUser)
Spacer(modifier = Modifier.height(32.dp))
ButtonRowMolecule(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedButton(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.action_cancel),
onClick = onDismiss
)
Button(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.action_continue),
onClick = onSendInvite
)
}
Spacer(modifier = Modifier.height(32.dp))
} else {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = titleContent,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = descriptionContent,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onSendInvite,
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onDismiss,
text = stringResource(CommonStrings.action_cancel),
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(
CreateDmConfirmationBottomSheetStateProvider::class
) state: CreateDmConfirmationBottomSheetState) = ElementPreview {
CreateDmConfirmationBottomSheet(
matrixUser = matrixUser,
matrixUser = state.matrixUser,
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
isUserIdentityUnknown = state.isUserIdentityUnknown,
onSendInvite = {},
onDismiss = {},
)
}
data class CreateDmConfirmationBottomSheetState(
val matrixUser: MatrixUser,
val enableKeyShareOnInvite: Boolean,
val isUserIdentityUnknown: Boolean,
)
class CreateDmConfirmationBottomSheetStateProvider : PreviewParameterProvider<CreateDmConfirmationBottomSheetState> {
override val values = sequenceOf(
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = false, isUserIdentityUnknown = false),
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = false),
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = true),
)
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -34,51 +35,34 @@ import io.element.android.libraries.matrix.ui.model.getBestName
@Composable
fun MatrixUserHeader(
matrixUser: MatrixUser?,
modifier: Modifier = Modifier,
// TODO handle click on this item, to let the user be able to update their profile.
// onClick: () -> Unit,
) {
if (matrixUser == null) {
MatrixUserHeaderPlaceholder(modifier = modifier)
} else {
MatrixUserHeaderContent(
matrixUser = matrixUser,
modifier = modifier,
// onClick = onClick
)
}
}
@Composable
private fun MatrixUserHeaderContent(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
// onClick: () -> Unit,
) {
Row(
modifier = modifier
// .clickable(onClick = onClick)
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
modifier = Modifier
.padding(vertical = 12.dp),
.padding(vertical = 7.dp),
avatarData = matrixUser.getAvatarData(size = AvatarSize.UserPreference),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(16.dp))
Spacer(modifier = Modifier.width(13.dp))
Column(
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// Name
Text(
modifier = Modifier.clipToBounds(),
text = matrixUser.getBestName(),
maxLines = 1,
style = ElementTheme.typography.fontHeadingSmMedium,
style = ElementTheme.typography.fontHeadingMdRegular,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary,
)

View file

@ -1,64 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.placeholderBackground
@Composable
fun MatrixUserHeaderPlaceholder(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.padding(vertical = 12.dp)
.size(AvatarSize.UserPreference.dp)
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
PlaceholderAtom(width = 80.dp, height = 7.dp)
Spacer(modifier = Modifier.height(16.dp))
PlaceholderAtom(width = 180.dp, height = 6.dp)
}
}
}
@PreviewsDayNight
@Composable
internal fun MatrixUserHeaderPlaceholderPreview() = ElementPreview {
MatrixUserHeaderPlaceholder()
}

View file

@ -20,15 +20,6 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
)
}
open class MatrixUserWithNullProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?>
get() = sequenceOf(
aMatrixUser(),
aMatrixUser(displayName = null),
null,
)
}
open class MatrixUserWithAvatarProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?>
get() = sequenceOf(

View file

@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -23,12 +25,14 @@ fun MatrixUserRow(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.UserListItem,
verticalSpaceWidth: Dp = 12.dp,
trailingContent: @Composable (() -> Unit)? = null,
) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier,
verticalSpaceWidth = verticalSpaceWidth,
trailingContent = trailingContent,
)

View file

@ -10,13 +10,16 @@ package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -31,22 +34,22 @@ internal fun UserRow(
subtext: String?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
verticalSpaceWidth: Dp = 12.dp,
trailingContent: @Composable (() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(verticalSpaceWidth))
Column(
modifier = Modifier
.padding(start = 12.dp)
.weight(1f),
modifier = Modifier.weight(1f),
) {
// Name
Text(

View file

@ -23,7 +23,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
@Composable

View file

@ -120,11 +120,25 @@ class MediaViewerDataSource(
*/
private fun buildMediaViewerPageList(groupedItems: List<MediaItem>) = buildList {
// Filter out DateSeparator items, we do not need them for the media viewer
val groupedItemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator }
pagerKeysHandler.accept(groupedItemsNoDateSeparator)
groupedItemsNoDateSeparator.forEach { mediaItem ->
val itemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator }
// Separate loading indicators and media events
val loadingIndicators = itemsNoDateSeparator.filterIsInstance<MediaItem.LoadingIndicator>()
val mediaEvents = itemsNoDateSeparator.filterIsInstance<MediaItem.Event>()
// Determine backward and forward loading indicators
val backwardLoading = loadingIndicators.find { it.direction == Timeline.PaginationDirection.BACKWARDS }
val forwardLoading = loadingIndicators.find { it.direction == Timeline.PaginationDirection.FORWARDS }
// Build ordered list: backward loading, media events (oldest first), forward loading
// Media events are currently newest first, reverse to get oldest first
val orderedEvents = mediaEvents.reversed()
// Create new list of MediaItem in order: backwardLoading, orderedEvents, forwardLoading
val orderedItems = buildList {
backwardLoading?.let { add(it) }
addAll(orderedEvents)
forwardLoading?.let { add(it) }
}
pagerKeysHandler.accept(orderedItems)
orderedItems.forEach { mediaItem ->
when (mediaItem) {
is MediaItem.DateSeparator -> Unit
is MediaItem.Event -> {
val sourceUrl = mediaItem.mediaSource().safeUrl
val localMedia = localMediaStates.getOrPut(sourceUrl) {
@ -148,6 +162,7 @@ class MediaViewerDataSource(
pagerKey = pagerKeysHandler.getKey(mediaItem),
)
)
is MediaItem.DateSeparator -> Unit // already filtered out
}
}
}.toImmutableList()

View file

@ -179,15 +179,19 @@ class MediaViewerPresenter(
) {
val isRenderingLoadingBackward by remember {
derivedStateOf {
currentIndex.intValue == data.value.lastIndex &&
currentIndex.intValue == 0 &&
data.value.size > 1 &&
data.value.lastOrNull() is MediaViewerPageData.Loading
data.value.firstOrNull() is MediaViewerPageData.Loading &&
(data.value.firstOrNull() as? MediaViewerPageData.Loading)?.direction == Timeline.PaginationDirection.BACKWARDS
}
}
if (isRenderingLoadingBackward) {
LaunchedEffect(Unit) {
// Observe the loading data vanishing
snapshotFlow { data.value.lastOrNull() is MediaViewerPageData.Loading }
snapshotFlow {
val first = data.value.firstOrNull()
first is MediaViewerPageData.Loading && first.direction == Timeline.PaginationDirection.BACKWARDS
}
.distinctUntilChanged()
.filter { !it }
.onEach { showNoMoreItemsSnackbar() }
@ -203,15 +207,19 @@ class MediaViewerPresenter(
) {
val isRenderingLoadingForward by remember {
derivedStateOf {
currentIndex.intValue == 0 &&
currentIndex.intValue == data.value.lastIndex &&
data.value.size > 1 &&
data.value.firstOrNull() is MediaViewerPageData.Loading
data.value.lastOrNull() is MediaViewerPageData.Loading &&
(data.value.lastOrNull() as? MediaViewerPageData.Loading)?.direction == Timeline.PaginationDirection.FORWARDS
}
}
if (isRenderingLoadingForward) {
LaunchedEffect(Unit) {
// Observe the loading data vanishing
snapshotFlow { data.value.firstOrNull() is MediaViewerPageData.Loading }
snapshotFlow {
val last = data.value.lastOrNull()
last is MediaViewerPageData.Loading && last.direction == Timeline.PaginationDirection.FORWARDS
}
.distinctUntilChanged()
.filter { !it }
.onEach { showNoMoreItemsSnackbar() }

View file

@ -29,6 +29,14 @@ import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirm
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import kotlinx.collections.immutable.toImmutableList
private const val LONG_CAPTION = "This is a very long caption that should be scrollable in the media viewer. " +
"It contains multiple lines of text to demonstrate the scrolling behavior. " +
"Line 1: Lorem ipsum dolor sit amet, consectetur adipiscing elit. " +
"Line 2: Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " +
"Line 3: Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. " +
"Line 4: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum. " +
"Line 5: Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia."
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
@ -170,6 +178,22 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
)
)
),
anImageMediaInfo(
senderName = "Alice",
dateSent = "21 NOV, 2024",
caption = LONG_CAPTION,
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
)
}

View file

@ -17,18 +17,24 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -39,14 +45,17 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -69,6 +78,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaSource
@ -102,8 +112,9 @@ fun MediaViewerView(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
val currentData = state.listData.getOrNull(state.currentIndex)
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current && !hasCompactHeightWindowSize()) 303 else 0
BackHandler { onBackClick() }
Scaffold(
modifier,
@ -153,10 +164,11 @@ fun MediaViewerView(
// So we need to update this value only when the `settledPage` value changes. It seems like a bug that needs to be fixed in Compose.
page == pagerState.settledPage
}
val navigationBarPadding = WindowInsets.navigationBars.getBottom(LocalDensity.current)
MediaViewerPage(
isDisplayed = isDisplayed,
showOverlay = showOverlay,
bottomPaddingInPixels = bottomPaddingInPixels,
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
data = dataForPage,
textFileViewer = textFileViewer,
onDismiss = onBackClick,
@ -175,9 +187,7 @@ fun MediaViewerView(
// Bottom bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
modifier = Modifier.fillMaxSize()
) {
MediaViewerBottomBar(
modifier = Modifier.align(Alignment.BottomCenter),
@ -538,19 +548,46 @@ private fun MediaViewerBottomBar(
if (showDivider) {
HorizontalDivider()
}
Text(
val scrollState = rememberScrollState()
val showBottomShadow by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
.heightIn(max = if (hasCompactHeightWindowSize()) maxCaptionHeightLandscape else maxCaptionHeightPortrait),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(scrollState)
.navigationBarsPadding(),
text = caption,
style = ElementTheme.typography.fontBodyLgRegular,
)
if (showBottomShadow) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.align(Alignment.BottomCenter)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
bgCanvasWithTransparency,
),
),
),
)
}
}
}
}
}
private val maxCaptionHeightPortrait = 200.dp
private val maxCaptionHeightLandscape = 128.dp
@Composable
private fun ThumbnailView(
thumbnailSource: MediaSource?,
@ -604,3 +641,14 @@ internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::
onBackClick = {},
)
}
@Preview(device = "${Devices.PHONE}, orientation=landscape")
@Composable
internal fun MediaViewerViewLandscapePreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
MediaViewerView(
state = state,
audioFocus = null,
textFileViewer = { _, _ -> },
onBackClick = {},
)
}

View file

@ -593,20 +593,20 @@ class MediaViewerPresenterTest {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(),
)
}
)
)
val updatedState = awaitItem()
// User navigate to the first item (forward loading indicator)
// User navigate to the last item (forward loading indicator)
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(0)
MediaViewerEvents.OnNavigateTo(2)
)
// data source claims that there is no more items to load forward
mediaGalleryDataSource.emitGroupedMediaItems(
@ -614,19 +614,21 @@ class MediaViewerPresenterTest {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(aBackwardLoadingIndicator, anImage),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage, aBackwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage),
fileItems = persistentListOf(),
)
}
)
)
skipItems(1)
val stateWithSnackbar = awaitItem()
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
var stateWithSnackbar = awaitItem()
while (stateWithSnackbar.snackbarMessage == null) {
stateWithSnackbar = awaitItem()
}
assertThat(stateWithSnackbar.snackbarMessage.messageResId).isEqualTo(expectedSnackbarResId)
}
}
@ -665,41 +667,42 @@ class MediaViewerPresenterTest {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(),
)
}
)
)
val updatedState = awaitItem()
// User navigate to the last item (backward loading indicator)
// User navigate to the first item (backward loading indicator)
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(2)
MediaViewerEvents.OnNavigateTo(0)
)
skipItems(1)
// data source claims that there is no more items to load backward
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage),
fileItems = persistentListOf(anImage, aForwardLoadingIndicator),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage),
imageAndVideoItems = persistentListOf(anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(),
)
}
)
)
skipItems(1)
val stateWithSnackbar = awaitItem()
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
var stateWithSnackbar = awaitItem()
while (stateWithSnackbar.snackbarMessage == null) {
stateWithSnackbar = awaitItem()
}
assertThat(stateWithSnackbar.snackbarMessage.messageResId).isEqualTo(expectedSnackbarResId)
}
}
@ -717,7 +720,7 @@ class MediaViewerPresenterTest {
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(),
)
)

View file

@ -76,7 +76,6 @@ class DefaultSyncPendingNotificationsRequestBuilder(
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
// If we're in an air-gapped environment, we shouldn't validate internet connectivity, as the checker will fail and the worker won't run at all.
// Note this will always be false for FOSS, since the feature is only enabled in Element Pro.
if (networkMonitor.isInAirGappedEnvironment.first()) {
Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request")
networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)

View file

@ -80,6 +80,9 @@ class DefaultSyncPendingNotificationsRequestBuilderTest {
sessionId = A_SESSION_ID,
sdkVersion = 33,
isInAirGapEnvironment = false,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(
FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching.key to true
)),
)
val results = request.build()
@ -100,6 +103,9 @@ class DefaultSyncPendingNotificationsRequestBuilderTest {
sessionId = A_SESSION_ID,
sdkVersion = 33,
isInAirGapEnvironment = true,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(
FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching.key to true
)),
)
val results = request.build()

View file

@ -51,6 +51,7 @@ sealed interface SlashCommand {
data class ChangeDisplayName(val displayName: String) : SlashCommandAdmin
data class ChangeDisplayNameForRoom(val displayName: String) : SlashCommandAdmin
data class ChangeRoomAvatar(val url: String) : SlashCommandAdmin
data class ChangeAvatar(val url: String) : SlashCommandAdmin
data class ChangeAvatarForRoom(val url: String) : SlashCommandAdmin
data class SendSpoiler(val message: String) : SlashCommandSendMessage
data class SendWithPrefix(val prefix: MessagePrefix, val message: CharSequence) : SlashCommandSendMessage

View file

@ -120,6 +120,15 @@ enum class Command(
isDevCommand = true,
isSupported = false,
),
CHANGE_AVATAR(
command = "/myavatar",
parameters = "<mxc_url>",
description = R.string.slash_command_description_avatar,
isAllowedInThread = false,
// Dev command since user has to know the mxc url
isDevCommand = true,
isSupported = false,
),
CHANGE_AVATAR_FOR_ROOM(
command = "/myroomavatar",
parameters = "<mxc_url>",

View file

@ -44,6 +44,7 @@ class CommandExecutor(
): Result<Unit> {
return when (slashCommand) {
is SlashCommand.BanUser -> banUser(slashCommand)
is SlashCommand.ChangeAvatar -> changeAvatar()
is SlashCommand.ChangeAvatarForRoom -> changeAvatarForRoom()
is SlashCommand.ChangeDisplayName -> changeDisplayName(slashCommand)
is SlashCommand.ChangeDisplayNameForRoom -> changeDisplayNameForRoom()
@ -178,6 +179,10 @@ class CommandExecutor(
return matrixClient.setDisplayName(slashCommand.displayName)
}
private fun changeAvatar(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private fun changeAvatarForRoom(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}

View file

@ -107,6 +107,18 @@ class CommandParser(
syntaxError(Command.ROOM_AVATAR)
}
}
Command.CHANGE_AVATAR.matches(slashCommand) -> {
if (messageParts.size == 2) {
val url = messageParts[1]
if (url.isMxcUrl()) {
SlashCommand.ChangeAvatar(url)
} else {
syntaxError(Command.CHANGE_AVATAR)
}
} else {
syntaxError(Command.CHANGE_AVATAR)
}
}
Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> {
if (messageParts.size == 2) {
val url = messageParts[1]

View file

@ -11,6 +11,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.slashcommands.api.SlashCommand
@ -18,6 +19,8 @@ import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(RoomScope::class)
class DefaultSlashCommandService(
@ -26,6 +29,7 @@ class DefaultSlashCommandService(
private val stringProvider: StringProvider,
private val appPreferencesStore: AppPreferencesStore,
private val featureFlagService: FeatureFlagService,
private val capabilitiesProvider: HomeserverCapabilitiesProvider,
) : SlashCommandService {
override suspend fun getSuggestions(
text: String,
@ -33,19 +37,41 @@ class DefaultSlashCommandService(
): List<SlashCommandSuggestion> {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList()
val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first()
return Command.entries.filter {
it.startsWith(text)
}.filter {
!isInThread || it.isAllowedInThread
}.filter {
!it.isDevCommand || isDeveloperModeEnabled
}.map {
SlashCommandSuggestion(
command = it.command,
parameters = it.parameters,
description = stringProvider.getString(it.description),
)
}
return Command.entries
.asSequence()
.filter { it.startsWith(text) }
.filter { !isInThread || it.isAllowedInThread }
.filter { !it.isDevCommand || isDeveloperModeEnabled }
// Don't include the change display name commands if the user can't change their display name
.run {
val canUserChangeDisplayName = withTimeoutOrNull(5.seconds) {
capabilitiesProvider.canChangeDisplayName().getOrNull()
} ?: false
if (!canUserChangeDisplayName) {
filterNot { it == Command.CHANGE_DISPLAY_NAME || it == Command.CHANGE_DISPLAY_NAME_FOR_ROOM }
} else {
this
}
}
// Don't include the change avatar commands if the user can't change their avatar url
.run {
val canUserChangeAvatar = withTimeoutOrNull(5.seconds) {
capabilitiesProvider.canChangeAvatarUrl().getOrNull()
} ?: false
if (!canUserChangeAvatar) {
filterNot { it == Command.CHANGE_AVATAR || it == Command.CHANGE_AVATAR_FOR_ROOM }
} else {
this
}
}
.map {
SlashCommandSuggestion(
command = it.command,
parameters = it.parameters,
description = stringProvider.getString(it.description),
)
}
.toList()
}
override suspend fun parse(

View file

@ -26,6 +26,7 @@
<string name="slash_command_description_topic">Set the room topic</string>
<string name="slash_command_description_remove_user">Removes user with given id from this room</string>
<string name="slash_command_description_nick">Changes your display nickname</string>
<string name="slash_command_description_avatar">Changes your profile picture in all rooms</string>
<string name="slash_command_confetti">Sends the given message with confetti</string>
<string name="slash_command_snow">Sends the given message with snowfall</string>
<string name="slash_command_description_plain">Sends a message as plain text, without interpreting it as markdown</string>

View file

@ -78,12 +78,19 @@ class CommandParserTest {
}
@Test
fun parseSlashCommandPlainAndNick() = runTest {
fun parseSlashCommandPlain() = runTest {
test("/plain hello", SlashCommand.SendPlainText("hello"))
test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain <message>"))
}
@Test
fun parseSlashCommandNickAndMyAvatar() = runTest {
test("/nick John", SlashCommand.ChangeDisplayName("John"))
test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick <display-name>"))
test("/myavatar mxc://matrix.org/abc", SlashCommand.ChangeAvatar("mxc://matrix.org/abc"))
test("/myavatar http://notmxc", SlashCommand.ErrorSyntax("A string/myavatar, /myavatar <mxc_url>"))
test("/myavatar", SlashCommand.ErrorSyntax("A string/myavatar, /myavatar <mxc_url>"))
}
@Test

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.test.FakeHomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@ -116,6 +117,26 @@ class DefaultSlashCommandServiceTest {
sendMessage.assertions().isCalledOnce()
}
@Test
fun `canChangeDisplayName is respected in suggestions`() = runTest {
var result = false
val capabilitiesProvider = FakeHomeserverCapabilitiesProvider(
canChangeDisplayName = { Result.success(result) },
)
val sut = createDefaultSlashCommandService(capabilitiesProvider = capabilitiesProvider)
// Initially, with a disabled capability, the change display name command should not be in the suggestions
var changeNameCommand = sut.getSuggestions("", isInThread = false)
.find { it.command == Command.CHANGE_DISPLAY_NAME.command }
assertThat(changeNameCommand).isNull()
// When the capability is true, the command should be included in the suggestions
result = true
changeNameCommand = sut.getSuggestions("", isInThread = false)
.find { it.command == Command.CHANGE_DISPLAY_NAME.command }
assertThat(changeNameCommand).isNotNull()
}
@Test
fun `proceedAdmin delegates to commandExecutor`() = runTest {
val leaveRoomLambda = lambdaRecorder<Result<Unit>> {
@ -155,11 +176,13 @@ class DefaultSlashCommandServiceTest {
commandExecutor: CommandExecutor = createCommandExecutor(
stringProvider = stringProvider,
),
capabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
) = DefaultSlashCommandService(
commandParser = commandParser,
commandExecutor = commandExecutor,
stringProvider = stringProvider,
appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService,
capabilitiesProvider = capabilitiesProvider,
)
}