Take into account homeserver capabilities (#6507)

* Take into account homeserver capabilities: add `HomeserverCapabilitiesProvider` to check if the HS allows changing the user's display name or avatar. Also, modify the edit user profile screen to reflect these values.

* Add `/myavatar` command. Filter both `/nick` and `/myavatar` commands based on the homeserver capabilities.

* Update screenshots

* Assume the use can change their display name and avatar url if the capabilities check fails: if they try to change those, the HS will return an error anyway.

* Disable also `/myroomname` and `/myroomavatar` based on the HS capabilities.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2026-04-15 14:29:41 +02:00 committed by GitHub
parent 80470b3792
commit 66513bc905
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 363 additions and 14 deletions

View file

@ -21,6 +21,8 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.CryptoSessionStateChange import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
@ -56,6 +58,7 @@ class LoggedInPresenter(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
@Composable @Composable
override fun present(): LoggedInState { override fun present(): LoggedInState {
@ -107,6 +110,14 @@ class LoggedInPresenter(
}.launchIn(this) }.launchIn(this)
} }
val networkConnectivity by networkMonitor.connectivity.collectAsState()
LaunchedEffect(networkConnectivity) {
if (networkConnectivity == NetworkStatus.Connected) {
// Refresh homeserver capabilities when the network is back
matrixClient.homeserverCapabilities().refresh()
}
}
fun handleEvent(event: LoggedInEvents) { fun handleEvent(event: LoggedInEvents) {
when (event) { when (event) {
is LoggedInEvents.CloseErrorDialog -> { is LoggedInEvents.CloseErrorDialog -> {

View file

@ -14,6 +14,8 @@ import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -27,6 +29,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeHomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
@ -109,6 +112,7 @@ class LoggedInPresenterTest {
val verificationService = FakeSessionVerificationService() val verificationService = FakeSessionVerificationService()
val encryptionService = FakeEncryptionService() val encryptionService = FakeEncryptionService()
val buildMeta = aBuildMeta() val buildMeta = aBuildMeta()
val networkMonitor = FakeNetworkMonitor()
LoggedInPresenter( LoggedInPresenter(
matrixClient = FakeMatrixClient( matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
@ -122,6 +126,7 @@ class LoggedInPresenterTest {
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = encryptionService, encryptionService = encryptionService,
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor,
).test { ).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
@ -319,6 +324,27 @@ class LoggedInPresenterTest {
} }
} }
@Test
fun `present - refreshes homeserver capabilities when network is back`() = runTest {
val refreshLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val matrixClient = FakeMatrixClient(
homeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(refresh = refreshLambda),
accountManagementUrlResult = { Result.success(null) },
)
val networkMonitor = FakeNetworkMonitor()
createLoggedInPresenter(
matrixClient = matrixClient,
networkMonitor = networkMonitor,
).test {
awaitItem()
networkMonitor.connectivity.value = NetworkStatus.Connected
advanceUntilIdle()
refreshLambda.assertions().isCalledOnce()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1) skipItems(1)
return awaitItem() return awaitItem()
@ -334,6 +360,7 @@ class LoggedInPresenterTest {
accountManagementUrlResult = { Result.success(null) }, accountManagementUrlResult = { Result.success(null) },
), ),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
): LoggedInPresenter { ): LoggedInPresenter {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
@ -343,6 +370,7 @@ class LoggedInPresenterTest {
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = encryptionService, encryptionService = encryptionService,
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor,
) )
} }
} }

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@ -103,6 +104,14 @@ class EditUserProfilePresenter(
} }
} }
val homeserverCapabilities = matrixClient.homeserverCapabilities()
val canChangeDisplayName = produceState(true) {
value = homeserverCapabilities.canChangeDisplayName().getOrDefault(true)
}
val canChangeAvatar = produceState(true) {
value = homeserverCapabilities.canChangeAvatarUrl().getOrDefault(true)
}
val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
@ -169,6 +178,8 @@ class EditUserProfilePresenter(
saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading, saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading,
saveAction = saveAction.value, saveAction = saveAction.value,
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,
canChangeDisplayName = canChangeDisplayName.value,
canChangeAvatarUrl = canChangeAvatar.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -22,5 +22,7 @@ data class EditUserProfileState(
val saveButtonEnabled: Boolean, val saveButtonEnabled: Boolean,
val saveAction: AsyncAction<Unit>, val saveAction: AsyncAction<Unit>,
val cameraPermissionState: PermissionsState, val cameraPermissionState: PermissionsState,
val canChangeDisplayName: Boolean,
val canChangeAvatarUrl: Boolean,
val eventSink: (EditUserProfileEvent) -> Unit val eventSink: (EditUserProfileEvent) -> Unit
) )

View file

@ -22,6 +22,7 @@ open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfi
aEditUserProfileState(), aEditUserProfileState(),
aEditUserProfileState(userAvatarUrl = "example://uri"), aEditUserProfileState(userAvatarUrl = "example://uri"),
aEditUserProfileState(saveAction = AsyncAction.ConfirmingCancellation), aEditUserProfileState(saveAction = AsyncAction.ConfirmingCancellation),
aEditUserProfileState(canChangeAvatarUrl = false, canChangeDisplayName = false),
) )
} }
@ -33,6 +34,8 @@ fun aEditUserProfileState(
saveButtonEnabled: Boolean = true, saveButtonEnabled: Boolean = true,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized, saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
canChangeDisplayName: Boolean = true,
canChangeAvatarUrl: Boolean = true,
eventSink: (EditUserProfileEvent) -> Unit = {}, eventSink: (EditUserProfileEvent) -> Unit = {},
) = EditUserProfileState( ) = EditUserProfileState(
userId = userId, userId = userId,
@ -42,5 +45,7 @@ fun aEditUserProfileState(
saveButtonEnabled = saveButtonEnabled, saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction, saveAction = saveAction,
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,
canChangeDisplayName = canChangeDisplayName,
canChangeAvatarUrl = canChangeAvatarUrl,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -120,6 +120,7 @@ fun EditUserProfileView(
state = avatarPickerState, state = avatarPickerState,
onClick = ::onAvatarClick, onClick = ::onAvatarClick,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
enabled = state.canChangeAvatarUrl,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
@ -134,6 +135,7 @@ fun EditUserProfileView(
value = state.displayName, value = state.displayName,
placeholder = stringResource(CommonStrings.common_room_name_placeholder), placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true, singleLine = true,
enabled = state.canChangeDisplayName,
onValueChange = { state.eventSink(EditUserProfileEvent.UpdateDisplayName(it)) }, onValueChange = { state.eventSink(EditUserProfileEvent.UpdateDisplayName(it)) },
) )
} }

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. * Resets the cached client `well-known` config by the SDK.
*/ */
suspend fun resetWellKnownConfig(): Result<Unit> suspend fun resetWellKnownConfig(): Result<Unit>
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
} }
/** /**

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

View file

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

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.ClientDelegate
import org.matrix.rustcomponents.sdk.CreateRoomParameters import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.NoHandle
@ -50,6 +51,7 @@ class FakeFfiClient(
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() }, private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() }, private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
private val closeResult: () -> Unit = {}, private val closeResult: () -> Unit = {},
) : Client(NoHandle) { ) : Client(NoHandle) {
override fun userId(): String = userId override fun userId(): String = userId
@ -103,5 +105,9 @@ class FakeFfiClient(
return createRoomResult(request) return createRoomResult(request)
} }
override fun homeserverCapabilities(): HomeserverCapabilities {
return homeserverCapabilities
}
override fun close() = closeResult() 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,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 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.MatrixClient
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
@ -84,6 +85,7 @@ class FakeMatrixClient(
override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(), override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() }, private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = { private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
Result.success( Result.success(
@ -384,4 +386,8 @@ class FakeMatrixClient(
override suspend fun resetWellKnownConfig(): Result<Unit> { override suspend fun resetWellKnownConfig(): Result<Unit> {
return resetWellKnownConfigLambda() return resetWellKnownConfigLambda()
} }
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return homeserverCapabilitiesProvider
}
} }

View file

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

View file

@ -120,6 +120,15 @@ enum class Command(
isDevCommand = true, isDevCommand = true,
isSupported = false, 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( CHANGE_AVATAR_FOR_ROOM(
command = "/myroomavatar", command = "/myroomavatar",
parameters = "<mxc_url>", parameters = "<mxc_url>",

View file

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

View file

@ -107,6 +107,18 @@ class CommandParser(
syntaxError(Command.ROOM_AVATAR) 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) -> { Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> {
if (messageParts.size == 2) { if (messageParts.size == 2) {
val url = messageParts[1] 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.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.slashcommands.api.SlashCommand 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.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
class DefaultSlashCommandService( class DefaultSlashCommandService(
@ -26,6 +29,7 @@ class DefaultSlashCommandService(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val capabilitiesProvider: HomeserverCapabilitiesProvider,
) : SlashCommandService { ) : SlashCommandService {
override suspend fun getSuggestions( override suspend fun getSuggestions(
text: String, text: String,
@ -33,19 +37,41 @@ class DefaultSlashCommandService(
): List<SlashCommandSuggestion> { ): List<SlashCommandSuggestion> {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList() if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList()
val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first() val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first()
return Command.entries.filter { return Command.entries
it.startsWith(text) .asSequence()
}.filter { .filter { it.startsWith(text) }
!isInThread || it.isAllowedInThread .filter { !isInThread || it.isAllowedInThread }
}.filter { .filter { !it.isDevCommand || isDeveloperModeEnabled }
!it.isDevCommand || isDeveloperModeEnabled // Don't include the change display name commands if the user can't change their display name
}.map { .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( SlashCommandSuggestion(
command = it.command, command = it.command,
parameters = it.parameters, parameters = it.parameters,
description = stringProvider.getString(it.description), description = stringProvider.getString(it.description),
) )
} }
.toList()
} }
override suspend fun parse( 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_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_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_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_confetti">Sends the given message with confetti</string>
<string name="slash_command_snow">Sends the given message with snowfall</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> <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 @Test
fun parseSlashCommandPlainAndNick() = runTest { fun parseSlashCommandPlain() = runTest {
test("/plain hello", SlashCommand.SendPlainText("hello")) test("/plain hello", SlashCommand.SendPlainText("hello"))
test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain <message>")) test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain <message>"))
}
@Test
fun parseSlashCommandNickAndMyAvatar() = runTest {
test("/nick John", SlashCommand.ChangeDisplayName("John")) test("/nick John", SlashCommand.ChangeDisplayName("John"))
test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick <display-name>")) 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 @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.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.timeline.MsgType 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@ -116,6 +117,26 @@ class DefaultSlashCommandServiceTest {
sendMessage.assertions().isCalledOnce() 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 @Test
fun `proceedAdmin delegates to commandExecutor`() = runTest { fun `proceedAdmin delegates to commandExecutor`() = runTest {
val leaveRoomLambda = lambdaRecorder<Result<Unit>> { val leaveRoomLambda = lambdaRecorder<Result<Unit>> {
@ -155,11 +176,13 @@ class DefaultSlashCommandServiceTest {
commandExecutor: CommandExecutor = createCommandExecutor( commandExecutor: CommandExecutor = createCommandExecutor(
stringProvider = stringProvider, stringProvider = stringProvider,
), ),
capabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
) = DefaultSlashCommandService( ) = DefaultSlashCommandService(
commandParser = commandParser, commandParser = commandParser,
commandExecutor = commandExecutor, commandExecutor = commandExecutor,
stringProvider = stringProvider, stringProvider = stringProvider,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
capabilitiesProvider = capabilitiesProvider,
) )
} }

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d4dbcac95e6fd72ee2eb683a4f25d49f4d181be9b1386ec01670fb078bc46a52
size 18966

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:901c40d524de6de6fd70cbfcfc8b1d86cc896734254452535a07830587adafcf
size 18987