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:
parent
80470b3792
commit
66513bc905
26 changed files with 363 additions and 14 deletions
|
|
@ -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 -> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>",
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d4dbcac95e6fd72ee2eb683a4f25d49f4d181be9b1386ec01670fb078bc46a52
|
||||||
|
size 18966
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:901c40d524de6de6fd70cbfcfc8b1d86cc896734254452535a07830587adafcf
|
||||||
|
size 18987
|
||||||
Loading…
Add table
Add a link
Reference in a new issue