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 im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
||||
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.Presenter
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
|
|
@ -56,6 +58,7 @@ class LoggedInPresenter(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : Presenter<LoggedInState> {
|
||||
@Composable
|
||||
override fun present(): LoggedInState {
|
||||
|
|
@ -107,6 +110,14 @@ class LoggedInPresenter(
|
|||
}.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) {
|
||||
when (event) {
|
||||
is LoggedInEvents.CloseErrorDialog -> {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import app.cash.turbine.ReceiveTurbine
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
||||
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.matrix.api.MatrixClient
|
||||
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.test.AN_EXCEPTION
|
||||
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.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
|
|
@ -109,6 +112,7 @@ class LoggedInPresenterTest {
|
|||
val verificationService = FakeSessionVerificationService()
|
||||
val encryptionService = FakeEncryptionService()
|
||||
val buildMeta = aBuildMeta()
|
||||
val networkMonitor = FakeNetworkMonitor()
|
||||
LoggedInPresenter(
|
||||
matrixClient = FakeMatrixClient(
|
||||
roomListService = roomListService,
|
||||
|
|
@ -122,6 +126,7 @@ class LoggedInPresenterTest {
|
|||
analyticsService = analyticsService,
|
||||
encryptionService = encryptionService,
|
||||
buildMeta = buildMeta,
|
||||
networkMonitor = networkMonitor,
|
||||
).test {
|
||||
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
|
||||
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 {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
|
|
@ -334,6 +360,7 @@ class LoggedInPresenterTest {
|
|||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||
): LoggedInPresenter {
|
||||
return LoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
|
|
@ -343,6 +370,7 @@ class LoggedInPresenterTest {
|
|||
analyticsService = analyticsService,
|
||||
encryptionService = encryptionService,
|
||||
buildMeta = buildMeta,
|
||||
networkMonitor = networkMonitor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.compose.runtime.MutableState
|
|||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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 localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -169,6 +178,8 @@ class EditUserProfilePresenter(
|
|||
saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading,
|
||||
saveAction = saveAction.value,
|
||||
cameraPermissionState = cameraPermissionState,
|
||||
canChangeDisplayName = canChangeDisplayName.value,
|
||||
canChangeAvatarUrl = canChangeAvatar.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,5 +22,7 @@ data class EditUserProfileState(
|
|||
val saveButtonEnabled: Boolean,
|
||||
val saveAction: AsyncAction<Unit>,
|
||||
val cameraPermissionState: PermissionsState,
|
||||
val canChangeDisplayName: Boolean,
|
||||
val canChangeAvatarUrl: Boolean,
|
||||
val eventSink: (EditUserProfileEvent) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfi
|
|||
aEditUserProfileState(),
|
||||
aEditUserProfileState(userAvatarUrl = "example://uri"),
|
||||
aEditUserProfileState(saveAction = AsyncAction.ConfirmingCancellation),
|
||||
aEditUserProfileState(canChangeAvatarUrl = false, canChangeDisplayName = false),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +34,8 @@ fun aEditUserProfileState(
|
|||
saveButtonEnabled: Boolean = true,
|
||||
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
|
||||
canChangeDisplayName: Boolean = true,
|
||||
canChangeAvatarUrl: Boolean = true,
|
||||
eventSink: (EditUserProfileEvent) -> Unit = {},
|
||||
) = EditUserProfileState(
|
||||
userId = userId,
|
||||
|
|
@ -42,5 +45,7 @@ fun aEditUserProfileState(
|
|||
saveButtonEnabled = saveButtonEnabled,
|
||||
saveAction = saveAction,
|
||||
cameraPermissionState = cameraPermissionState,
|
||||
canChangeDisplayName = canChangeDisplayName,
|
||||
canChangeAvatarUrl = canChangeAvatarUrl,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ fun EditUserProfileView(
|
|||
state = avatarPickerState,
|
||||
onClick = ::onAvatarClick,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
enabled = state.canChangeAvatarUrl,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
|
|
@ -134,6 +135,7 @@ fun EditUserProfileView(
|
|||
value = state.displayName,
|
||||
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
|
||||
singleLine = true,
|
||||
enabled = state.canChangeDisplayName,
|
||||
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.
|
||||
*/
|
||||
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.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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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