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 15419bb675
commit b66df37f3e
26 changed files with 363 additions and 14 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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