Ensure that the keyboard focus and accessibility focus is not lost when deleting a pending voice message.

This commit is contained in:
Benoit Marty 2026-01-08 12:09:29 +01:00
parent 9f9a017ffa
commit 75fc734892
2 changed files with 76 additions and 74 deletions

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
@ -143,26 +144,6 @@ fun TextComposer(
.fillMaxSize() .fillMaxSize()
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
@Composable {
when (composerMode) {
is MessageComposerMode.Attachment -> {
Spacer(modifier = Modifier.width(9.dp))
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(16.dp))
}
else -> {
IconColorButton(
onClick = onAddAttachment,
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
)
}
}
}
}
val placeholder = if (composerMode.inThread) { val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread) stringResource(id = CommonStrings.action_reply_in_thread)
} else if (composerMode is MessageComposerMode.Attachment || composerMode is MessageComposerMode.EditCaption) { } else if (composerMode is MessageComposerMode.Attachment || composerMode is MessageComposerMode.EditCaption) {
@ -337,16 +318,6 @@ fun TextComposer(
} }
} }
val voiceDeleteButton = @Composable {
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
is VoiceMessageState.Recording ->
VoiceMessageDeleteButton(enabled = true, onClick = { onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel) })
else -> {}
}
}
if (showTextFormatting && textFormattingOptions != null) { if (showTextFormatting && textFormattingOptions != null) {
TextFormattingLayout( TextFormattingLayout(
modifier = layoutModifier, modifier = layoutModifier,
@ -366,16 +337,18 @@ fun TextComposer(
) )
} else { } else {
StandardLayout( StandardLayout(
composerMode = composerMode,
voiceMessageState = voiceMessageState, voiceMessageState = voiceMessageState,
isRoomEncrypted = state.isRoomEncrypted, isRoomEncrypted = state.isRoomEncrypted,
modifier = layoutModifier, modifier = layoutModifier,
composerOptionsButton = composerOptionsButton,
textInput = textInput, textInput = textInput,
endButton = sendOrRecordButton, endButton = sendOrRecordButton,
endButtonClick = ::endButtonClickStandard, endButtonClick = ::endButtonClickStandard,
endButtonA11y = endButtonA11y, endButtonA11y = endButtonA11y,
voiceRecording = voiceRecording, voiceRecording = voiceRecording,
voiceDeleteButton = voiceDeleteButton, onAddAttachment = onAddAttachment,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onVoiceRecorderEvent = onVoiceRecorderEvent,
) )
} }
@ -434,15 +407,17 @@ private fun endButtonA11y(
@Composable @Composable
private fun StandardLayout( private fun StandardLayout(
composerMode: MessageComposerMode,
voiceMessageState: VoiceMessageState, voiceMessageState: VoiceMessageState,
isRoomEncrypted: Boolean?, isRoomEncrypted: Boolean?,
textInput: @Composable () -> Unit, textInput: @Composable () -> Unit,
composerOptionsButton: @Composable () -> Unit,
voiceRecording: @Composable () -> Unit, voiceRecording: @Composable () -> Unit,
voiceDeleteButton: @Composable () -> Unit,
endButton: @Composable () -> Unit, endButton: @Composable () -> Unit,
endButtonClick: () -> Unit, endButtonClick: () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit), endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
onAddAttachment: () -> Unit,
onDeleteVoiceMessage: () -> Unit,
onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -452,21 +427,54 @@ private fun StandardLayout(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
if (voiceMessageState is VoiceMessageState.Idle) { when (composerMode) {
Box( is MessageComposerMode.Attachment -> {
Modifier Spacer(modifier = Modifier.width(12.dp))
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
) {
composerOptionsButton()
} }
} else { is MessageComposerMode.EditCaption -> {
Box( Spacer(modifier = Modifier.width(19.dp))
modifier = Modifier }
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) else -> {
.size(48.dp), val endPadding = if (voiceMessageState is VoiceMessageState.Idle) 0.dp else 3.dp
contentAlignment = Alignment.Center, // To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
) { IconButton(
voiceDeleteButton() modifier = Modifier
.padding(top = 5.dp, bottom = 5.dp, start = 3.dp, end = endPadding)
.size(48.dp),
onClick = {
if (voiceMessageState is VoiceMessageState.Idle) {
onAddAttachment()
} else {
when (voiceMessageState) {
is VoiceMessageState.Preview -> if (!voiceMessageState.isSending) {
onDeleteVoiceMessage()
}
is VoiceMessageState.Recording ->
onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel)
}
}
},
) {
if (voiceMessageState is VoiceMessageState.Idle) {
Icon(
modifier = Modifier
.clip(CircleShape)
.size(30.dp)
.background(ElementTheme.colors.iconPrimary)
.padding(3.dp),
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
tint = ElementTheme.colors.iconOnSolidPrimary
)
} else {
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending)
is VoiceMessageState.Recording ->
VoiceMessageDeleteButton(enabled = true)
}
}
}
} }
} }
Box( Box(

View file

@ -25,39 +25,33 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun VoiceMessageDeleteButton( fun VoiceMessageDeleteButton(
enabled: Boolean, enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
IconButton( Icon(
modifier = modifier modifier = modifier.size(24.dp),
.size(48.dp), imageVector = CompoundIcons.Delete(),
enabled = enabled, contentDescription = stringResource(CommonStrings.a11y_delete),
onClick = onClick, tint = if (enabled) {
) { ElementTheme.colors.iconCriticalPrimary
Icon( } else {
modifier = Modifier.size(24.dp), ElementTheme.colors.iconDisabled
imageVector = CompoundIcons.Delete(), },
contentDescription = stringResource(CommonStrings.a11y_delete), )
tint = if (enabled) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconDisabled
},
)
}
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun VoiceMessageDeleteButtonPreview() = ElementPreview { internal fun VoiceMessageDeleteButtonPreview() = ElementPreview {
Row { Row {
VoiceMessageDeleteButton( IconButton(onClick = {}) {
enabled = true, VoiceMessageDeleteButton(
onClick = {}, enabled = true,
) )
VoiceMessageDeleteButton( }
enabled = false, IconButton(onClick = {}) {
onClick = {}, VoiceMessageDeleteButton(
) enabled = false,
)
}
} }
} }