[a11y] voice message improvements (#5980)

* A11Y: ensure a11y focus is not lost and reset to the back button when the user start playing a pending voice message.

* A11Y: ensure a11y focus is not lost and reset to the back button when the user use the keyboard to focus the send button and press the space bar to perform a click.

* Cleanup code. This if was not necessary.

* Small rework to prepare a bugfix. No behavior / UI change.

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

* Update screenshots

* Improve code readability.

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2026-01-13 09:23:17 +01:00 committed by GitHub
commit e311a719e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 336 additions and 317 deletions

View file

@ -27,9 +27,9 @@ 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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -39,9 +39,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.hideFromAccessibility
@ -61,6 +62,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconColorButton import io.element.android.libraries.designsystem.theme.components.IconColorButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -70,11 +72,11 @@ import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.textcomposer.components.SendButton import io.element.android.libraries.textcomposer.components.SendButtonIcon
import io.element.android.libraries.textcomposer.components.TextFormatting import io.element.android.libraries.textcomposer.components.TextFormatting
import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButtonIcon
import io.element.android.libraries.textcomposer.components.VoiceMessagePreview import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButtonIcon
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
@ -123,9 +125,6 @@ fun TextComposer(
is TextEditorState.Markdown -> state.state.text.value() is TextEditorState.Markdown -> state.state.text.value()
is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown
} }
val onSendClick = {
onSendMessage()
}
val onPlayVoiceMessageClick = { val onPlayVoiceMessageClick = {
onVoicePlayerEvent(VoiceMessagePlayerEvent.Play) onVoicePlayerEvent(VoiceMessagePlayerEvent.Play)
@ -143,26 +142,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) {
@ -234,55 +213,137 @@ fun TextComposer(
} }
} }
val canSendMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment val canSendTextMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment
val sendButton = @Composable {
SendButton(
canSendMessage = canSendMessage,
onClick = onSendClick,
composerMode = composerMode,
)
}
val recordVoiceButton = @Composable {
VoiceMessageRecorderButton(
isRecording = voiceMessageState is VoiceMessageState.Recording,
onEvent = onVoiceRecorderEvent,
)
}
val sendVoiceButton = @Composable {
SendButton(
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
onClick = onSendVoiceMessage,
composerMode = composerMode,
)
}
val uploadVoiceProgress = @Composable {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let { val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let {
@Composable { TextFormatting(state = it.richTextEditorState) } @Composable { TextFormatting(state = it.richTextEditorState) }
} }
val sendOrRecordButton = when { val hapticFeedback = LocalHapticFeedback.current
!canSendMessage ->
when (voiceMessageState) { fun performHapticFeedback() {
VoiceMessageState.Idle, hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
is VoiceMessageState.Recording -> recordVoiceButton
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> uploadVoiceProgress
false -> sendVoiceButton
}
}
else -> sendButton
} }
val endButtonA11y = endButtonA11y( @Composable
composerMode = composerMode, fun rememberEndButtonParams() = remember(
voiceMessageState = voiceMessageState, composerMode.isEditing,
canSendMessage = canSendMessage, voiceMessageState.endButtonKey(),
) canSendTextMessage,
) {
when {
!canSendTextMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.a11y_voice_message_record,
endButtonClick = {
performHapticFeedback()
onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Start)
},
endButtonContent = @Composable {
VoiceMessageRecorderButtonIcon(
isRecording = false,
)
}
)
is VoiceMessageState.Recording -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.a11y_voice_message_stop_recording,
endButtonClick = {
performHapticFeedback()
onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Stop)
},
endButtonContent = @Composable {
VoiceMessageRecorderButtonIcon(
isRecording = true,
)
}
)
is VoiceMessageState.Preview -> if (voiceMessageState.isSending) {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.common_sending,
endButtonClick = {},
endButtonContent = @Composable {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
)
} else {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_voice_message,
endButtonClick = {
onSendVoiceMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = composerMode.isEditing,
)
},
)
}
}
composerMode.isEditing -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
endButtonClick = {
onSendMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = true,
)
},
)
else -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_message,
endButtonClick = {
onSendMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = false,
)
},
)
}
}
@Composable
fun rememberEndButtonParamsFormatting() = remember(composerMode.isEditing, canSendTextMessage) {
if (composerMode.isEditing) {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
endButtonClick = {
if (canSendTextMessage) {
onSendMessage()
}
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = canSendTextMessage,
isEditing = true,
)
},
)
} else {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_message,
endButtonClick = {
if (canSendTextMessage) {
onSendMessage()
}
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = canSendTextMessage,
isEditing = false,
)
},
)
}
}
val voiceRecording = @Composable { val voiceRecording = @Composable {
when (voiceMessageState) { when (voiceMessageState) {
@ -307,17 +368,8 @@ 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) {
val endButtonParams = rememberEndButtonParamsFormatting()
TextFormattingLayout( TextFormattingLayout(
modifier = layoutModifier, modifier = layoutModifier,
isRoomEncrypted = state.isRoomEncrypted, isRoomEncrypted = state.isRoomEncrypted,
@ -330,20 +382,21 @@ fun TextComposer(
) )
}, },
textFormatting = textFormattingOptions, textFormatting = textFormattingOptions,
endButtonA11y = endButtonA11y, endButtonParams = endButtonParams,
sendButton = sendButton,
) )
} else { } else {
val endButtonParams = rememberEndButtonParams()
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, endButtonParams = endButtonParams,
endButtonA11y = endButtonA11y,
voiceRecording = voiceRecording, voiceRecording = voiceRecording,
voiceDeleteButton = voiceDeleteButton, onAddAttachment = onAddAttachment,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onVoiceRecorderEvent = onVoiceRecorderEvent,
) )
} }
@ -367,49 +420,23 @@ fun TextComposer(
} }
} }
@ReadOnlyComposable private data class EndButtonParams(
@Composable val endButtonContentDescriptionResId: Int,
private fun endButtonA11y( val endButtonClick: () -> Unit,
composerMode: MessageComposerMode, val endButtonContent: @Composable () -> Unit,
voiceMessageState: VoiceMessageState, )
canSendMessage: Boolean,
): (SemanticsPropertyReceiver) -> Unit {
val a11ySendButtonDescription = stringResource(
id = when {
!canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> if (voiceMessageState is VoiceMessageState.Recording) {
CommonStrings.a11y_voice_message_stop_recording
} else {
CommonStrings.a11y_voice_message_record
}
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> CommonStrings.common_sending
false -> CommonStrings.action_send_voice_message
}
}
composerMode.isEditing -> CommonStrings.action_send_edited_message
else -> CommonStrings.action_send_message
}
)
val endButtonA11y: (SemanticsPropertyReceiver.() -> Unit) = {
contentDescription = a11ySendButtonDescription
onClick(null, null)
}
return 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, endButtonParams: EndButtonParams,
endButton: @Composable () -> Unit, onAddAttachment: () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit), onDeleteVoiceMessage: () -> Unit,
onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -419,50 +446,80 @@ 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) {
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) { is MessageComposerMode.Attachment -> {
Box( Spacer(modifier = Modifier.width(12.dp))
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(19.dp))
}
else -> {
val endPadding = if (voiceMessageState is VoiceMessageState.Idle) 0.dp else 3.dp
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
IconButton(
modifier = Modifier modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) .padding(top = 5.dp, bottom = 5.dp, start = 3.dp, end = endPadding)
.size(48.dp), .size(48.dp),
contentAlignment = Alignment.Center, onClick = {
if (voiceMessageState is VoiceMessageState.Idle) {
onAddAttachment()
} else {
when (voiceMessageState) {
is VoiceMessageState.Preview -> if (!voiceMessageState.isSending) {
onDeleteVoiceMessage()
}
is VoiceMessageState.Recording ->
onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel)
}
}
},
) { ) {
voiceDeleteButton() 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 ->
VoiceMessageDeleteButtonIcon(enabled = !voiceMessageState.isSending)
is VoiceMessageState.Recording ->
VoiceMessageDeleteButtonIcon(enabled = true)
}
}
} }
} else {
Spacer(modifier = Modifier.width(16.dp))
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
voiceRecording()
}
} else {
Box(
Modifier
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
) {
composerOptionsButton()
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
textInput()
} }
} }
Box( Box(
Modifier modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
if (voiceMessageState is VoiceMessageState.Idle) {
textInput()
} else {
voiceRecording()
}
}
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
val endButtonContentDescription = stringResource(endButtonParams.endButtonContentDescriptionResId)
IconButton(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp) .size(48.dp)
.clearAndSetSemantics(endButtonA11y), .clearAndSetSemantics {
contentAlignment = Alignment.Center, contentDescription = endButtonContentDescription
) { onClick(null, null)
endButton() },
} onClick = endButtonParams.endButtonClick,
content = endButtonParams.endButtonContent,
)
} }
} }
} }
@ -495,8 +552,7 @@ private fun TextFormattingLayout(
textInput: @Composable () -> Unit, textInput: @Composable () -> Unit,
dismissTextFormattingButton: @Composable () -> Unit, dismissTextFormattingButton: @Composable () -> Unit,
textFormatting: @Composable () -> Unit, textFormatting: @Composable () -> Unit,
sendButton: @Composable () -> Unit, endButtonParams: EndButtonParams,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( Column(
@ -527,16 +583,22 @@ private fun TextFormattingLayout(
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
textFormatting() textFormatting()
} }
Box( // To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
val endButtonContentDescription = stringResource(endButtonParams.endButtonContentDescriptionResId)
IconButton(
modifier = Modifier modifier = Modifier
.padding( .padding(
start = 14.dp, start = 14.dp,
end = 6.dp, end = 6.dp,
) )
.clearAndSetSemantics(endButtonA11y) .size(48.dp)
) { .clearAndSetSemantics {
sendButton() contentDescription = endButtonContentDescription
} onClick(null, null)
},
onClick = endButtonParams.endButtonClick,
content = endButtonParams.endButtonContent,
)
} }
} }
} }
@ -596,6 +658,12 @@ private fun TextInputBox(
} }
} }
private fun VoiceMessageState.endButtonKey() = when (this) {
is VoiceMessageState.Idle -> "Idle"
is VoiceMessageState.Preview -> "Preview_$isSending"
is VoiceMessageState.Recording -> "Recording"
}
private fun aTextEditorStateMarkdownList(isRoomEncrypted: Boolean? = null) = persistentListOf( private fun aTextEditorStateMarkdownList(isRoomEncrypted: Boolean? = null) = persistentListOf(
aTextEditorStateMarkdown(initialText = "", initialFocus = true, isRoomEncrypted = isRoomEncrypted), aTextEditorStateMarkdown(initialText = "", initialFocus = true, isRoomEncrypted = isRoomEncrypted),
aTextEditorStateMarkdown(initialText = "A message", initialFocus = true, isRoomEncrypted = isRoomEncrypted), aTextEditorStateMarkdown(initialText = "A message", initialFocus = true, isRoomEncrypted = isRoomEncrypted),

View file

@ -29,9 +29,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.textcomposer.model.MessageComposerMode
/** /**
* Send button for the message composer. * Send button for the message composer.
@ -39,50 +36,42 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
* Temporary Figma : https://www.figma.com/design/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?node-id=2274-39944&m=dev * Temporary Figma : https://www.figma.com/design/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?node-id=2274-39944&m=dev
*/ */
@Composable @Composable
internal fun SendButton( internal fun SendButtonIcon(
canSendMessage: Boolean, canSendMessage: Boolean,
onClick: () -> Unit, isEditing: Boolean,
composerMode: MessageComposerMode,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
IconButton( val iconVector = when {
isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
isEditing -> 0.dp
else -> 2.dp
}
Box(
modifier = modifier modifier = modifier
.size(48.dp), .clip(CircleShape)
onClick = onClick, .size(36.dp)
enabled = canSendMessage, .buttonBackgroundModifier(canSendMessage)
) { ) {
val iconVector = when { Icon(
composerMode.isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
composerMode.isEditing -> 0.dp
else -> 2.dp
}
Box(
modifier = Modifier modifier = Modifier
.clip(CircleShape) .padding(start = iconStartPadding)
.size(36.dp) .align(Alignment.Center),
.buttonBackgroundModifier(canSendMessage) imageVector = iconVector,
) { // Note: accessibility is managed in TextComposer.
Icon( contentDescription = null,
modifier = Modifier tint = if (canSendMessage) {
.padding(start = iconStartPadding) if (ElementTheme.colors.isLight) {
.align(Alignment.Center), ElementTheme.colors.iconOnSolidPrimary
imageVector = iconVector,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconPrimary
}
} else { } else {
ElementTheme.colors.iconQuaternary ElementTheme.colors.iconPrimary
} }
) } else {
} ElementTheme.colors.iconQuaternary
}
)
} }
} }
@ -113,13 +102,19 @@ private fun Modifier.buttonBackgroundModifier(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun SendButtonPreview() = ElementPreview { internal fun SendButtonIconPreview() = ElementPreview {
val normalMode = MessageComposerMode.Normal
val editMode = MessageComposerMode.Edit(EventId("\$id").toEventOrTransactionId(), "")
Row { Row {
SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode) IconButton(onClick = {}) {
SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode) SendButtonIcon(canSendMessage = true, isEditing = false)
SendButton(canSendMessage = true, onClick = {}, composerMode = editMode) }
SendButton(canSendMessage = false, onClick = {}, composerMode = editMode) IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = false, isEditing = false)
}
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = true, isEditing = true)
}
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = false, isEditing = true)
}
} }
} }

View file

@ -23,41 +23,35 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun VoiceMessageDeleteButton( fun VoiceMessageDeleteButtonIcon(
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 VoiceMessageDeleteButtonIconPreview() = ElementPreview {
Row { Row {
VoiceMessageDeleteButton( IconButton(onClick = {}) {
enabled = true, VoiceMessageDeleteButtonIcon(
onClick = {}, enabled = true,
) )
VoiceMessageDeleteButton( }
enabled = false, IconButton(onClick = {}) {
onClick = {}, VoiceMessageDeleteButtonIcon(
) enabled = false,
)
}
} }
} }

View file

@ -67,22 +67,12 @@ internal fun VoiceMessagePreview(
.heightIn(26.dp), .heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isPlaying) { PlayerButton(
PlayerButton( type = if (isPlaying) PlayerButtonType.Pause else PlayerButtonType.Play,
type = PlayerButtonType.Pause, onClick = if (isPlaying) onPauseClick else onPlayClick,
onClick = onPauseClick, enabled = isInteractive,
enabled = isInteractive, )
)
} else {
PlayerButton(
type = PlayerButtonType.Play,
onClick = onPlayClick,
enabled = isInteractive
)
}
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = time.formatShort(), text = time.formatShort(),
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
@ -90,9 +80,7 @@ internal fun VoiceMessagePreview(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
WaveformPlaybackView( WaveformPlaybackView(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)

View file

@ -14,9 +14,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@ -25,49 +24,25 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@Composable @Composable
internal fun VoiceMessageRecorderButton( internal fun VoiceMessageRecorderButtonIcon(
isRecording: Boolean, isRecording: Boolean,
onEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hapticFeedback = LocalHapticFeedback.current
val performHapticFeedback = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
if (isRecording) { if (isRecording) {
StopButton( StopButton(modifier)
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Stop)
}
)
} else { } else {
StartButton( StartButton(modifier)
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Start)
}
)
} }
} }
@Composable @Composable
private fun StartButton( private fun StartButton(
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier.size(48.dp),
onClick = onClick,
) { ) {
Icon( Icon(
modifier = Modifier.size(24.dp), modifier = modifier.size(24.dp),
imageVector = CompoundIcons.MicOn(), imageVector = CompoundIcons.MicOn(),
// Note: accessibility is managed in TextComposer. // Note: accessibility is managed in TextComposer.
contentDescription = null, contentDescription = null,
@ -77,41 +52,40 @@ private fun StartButton(
@Composable @Composable
private fun StopButton( private fun StopButton(
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier
.size(48.dp),
onClick = onClick,
) { ) {
Box( Box(
Modifier modifier
.size(36.dp) .size(36.dp)
.background( .background(
color = ElementTheme.colors.bgActionPrimaryRest, color = ElementTheme.colors.bgActionPrimaryRest,
shape = CircleShape, shape = CircleShape,
) ),
) contentAlignment = Alignment.Center,
Icon( ) {
modifier = Modifier.size(24.dp), Icon(
resourceId = CommonDrawables.ic_stop, modifier = Modifier.size(24.dp),
// Note: accessibility is managed in TextComposer. resourceId = CommonDrawables.ic_stop,
contentDescription = null, // Note: accessibility is managed in TextComposer.
tint = ElementTheme.colors.iconOnSolidPrimary, contentDescription = null,
) tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun VoiceMessageRecorderButtonPreview() = ElementPreview { internal fun VoiceMessageRecorderButtonIconPreview() = ElementPreview {
Row { Row {
VoiceMessageRecorderButton( IconButton(onClick = {}) {
isRecording = false, VoiceMessageRecorderButtonIcon(
onEvent = {}, isRecording = false,
) )
VoiceMessageRecorderButton( }
isRecording = true, IconButton(onClick = {}) {
onEvent = {}, VoiceMessageRecorderButtonIcon(
) isRecording = true,
)
}
} }
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:09faeef5f5864f93d81f271a67826acf417b228987c6f918b95b31f91a468cb1 oid sha256:74c638ff6c7ea4961af24b176c3f0b2b40ce123c5f058c4f1ba06c6b823cb16f
size 36097 size 36090

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:246d4bf81c16bbbcfc82dd23b23bb1471e3d5e078bc7ecaba3c68d2ab3bb75c2 oid sha256:8d220ce93b1e7a7a7330ee90f59415a07f49d040ad5c85ff8086b3dba882452d
size 34307 size 34313

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:274f54991bbd79c4220d945964caf7fc523ce99a70b748ef992cc15ee030fdf5 oid sha256:75d285698529499a08b5a6107bed61de35b30a0e225951fc93edc1c632a5c7ba
size 25124 size 25121

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:258643d514aeb7a53788ba2addc53fe46f888193ced2970c73887f9eb1584753 oid sha256:77ac0f986e20bea03f10f00a699484c31a7959eba2ffb4764f8c1e71428159ed
size 24039 size 24040