From a98fd963030dc47f1ac9be449755f645108fd10b Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Sat, 30 May 2026 14:14:55 +0000 Subject: [PATCH 01/64] Add skin tone picker --- .../components/customreaction/EmojiItem.kt | 163 ++++++++++++++++-- .../customreaction/picker/EmojiPicker.kt | 44 +++++ .../customreaction/picker/SkinTonePicker.kt | 131 ++++++++++++++ 3 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index 629eaa72ab..4a8d2e6cca 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -8,32 +8,51 @@ package io.element.android.features.messages.impl.timeline.components.customreaction +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup import io.element.android.compound.theme.ElementTheme import io.element.android.emojibasebindings.Emoji import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction +import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePadding +import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePickerContent +import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSize +import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSpacing import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.Text import kotlinx.collections.immutable.persistentListOf @@ -44,12 +63,22 @@ fun EmojiItem( onSelectEmoji: (Emoji) -> Unit, modifier: Modifier = Modifier, emojiSize: TextUnit = 20.sp, + onLongPress: ((Emoji) -> Unit)? = null, + skinPickerEmoji: Emoji? = null, + onDismissSkinPicker: (() -> Unit)? = null, + selectedSkinUnicodes: Set = emptySet(), + hasSelectedSkin: Boolean = false, ) { - val backgroundColor = if (isSelected) { - ElementTheme.colors.bgActionPrimaryRest - } else { - Color.Transparent + val backgroundColor = when { + isSelected -> ElementTheme.colors.bgActionPrimaryRest + hasSelectedSkin -> ElementTheme.colors.bgActionTertiarySelected + else -> Color.Transparent } + val density = LocalDensity.current + var itemSize by remember { mutableStateOf(IntSize.Zero) } + var hoveredIndex by remember { mutableStateOf(-1) } + var dismissed by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } val description = a11yReactionAction( emoji = item.unicode, userAlreadyReacted = isSelected, @@ -57,13 +86,88 @@ fun EmojiItem( Box( modifier = modifier .sizeIn(minWidth = 40.dp, minHeight = 40.dp) + .onSizeChanged { itemSize = it } .background(backgroundColor, CircleShape) - .clickable( - enabled = true, - onClick = { onSelectEmoji(item) }, - indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp), - interactionSource = remember { MutableInteractionSource() } - ) + .indication(interactionSource, ripple()) + .pointerInput(item) { + detectTapGestures( + onPress = { pressOffset -> + val press = PressInteraction.Press(pressOffset) + interactionSource.emit(press) + if (tryAwaitRelease()) { + interactionSource.emit(PressInteraction.Release(press)) + } else { + interactionSource.emit(PressInteraction.Cancel(press)) + } + }, + onTap = { onSelectEmoji(item) }, + ) + } + .pointerInput(item) { + detectDragGesturesAfterLongPress( + onDragStart = { _ -> + if (item.skins != null && onLongPress != null) { + dismissed = false + onLongPress(item) + hoveredIndex = -1 + } + }, + onDrag = { change, _ -> + if (item.skins != null && !dismissed) { + change.consume() + val yThresholdPx = with(density) { 100.dp.toPx() } + if (change.position.y > yThresholdPx) { + dismissed = true + hoveredIndex = -1 + onDismissSkinPicker?.invoke() + } else { + val skinCount = item.skins!!.size + val slotWidthPx = with(density) { SkinToneSlotSize.toPx() } + val spacingPx = with(density) { SkinToneSlotSpacing.toPx() } + val paddingPx = with(density) { SkinTonePadding.toPx() } + val totalSlots = 1 + skinCount + val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx + val pickerHalfWidthPx = pickerWidthPx / 2f + val centerXPx = itemSize.width / 2f + val pickerLeftPx = centerXPx - pickerHalfWidthPx + val xInPicker = change.position.x - pickerLeftPx + val index = if (xInPicker >= 0f && xInPicker <= pickerWidthPx) { + (xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1) + } else { + -1 + } + hoveredIndex = index + } + } + }, + onDragEnd = { + if (!dismissed) { + val idx = hoveredIndex + val skinCount = item.skins?.size ?: 0 + if (idx in 0..skinCount) { + if (idx == 0) { + onSelectEmoji(item) + } else { + val skin = item.skins?.getOrNull(idx - 1) + if (skin != null) { + onSelectEmoji(item.copy(unicode = skin.unicode)) + } + } + } else if (idx < 0) { + onSelectEmoji(item) + } + } + dismissed = false + hoveredIndex = -1 + onDismissSkinPicker?.invoke() + }, + onDragCancel = { + dismissed = false + hoveredIndex = -1 + onDismissSkinPicker?.invoke() + }, + ) + } .clearAndSetSemantics { contentDescription = description }, @@ -73,6 +177,41 @@ fun EmojiItem( text = item.unicode, style = LocalTextStyle.current.copy(fontSize = emojiSize), ) + if (item.skins != null) { + Canvas( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(8.dp), + ) { + val path = Path().apply { + moveTo(size.width, size.height) + lineTo(size.width, 0f) + lineTo(0f, size.height) + close() + } + drawPath(path, color = Color.Gray.copy(alpha = 0.5f), style = Fill) + } + } + + if (skinPickerEmoji == item) { + val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize + val popupOffsetPx = with(density) { -pickerHeight.roundToPx() } + Popup( + onDismissRequest = { onDismissSkinPicker?.invoke() }, + alignment = Alignment.BottomCenter, + offset = IntOffset(0, popupOffsetPx), + ) { + SkinTonePickerContent( + emoji = item, + onSelect = { selectedEmoji -> + onSelectEmoji(selectedEmoji) + onDismissSkinPicker?.invoke() + }, + hoveredIndex = hoveredIndex, + selectedUnicodes = selectedSkinUnicodes, + ) + } + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt index 0575341d3d..a0cd4928fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt @@ -25,7 +25,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter @@ -54,6 +58,26 @@ fun EmojiPicker( ) { val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState(pageCount = { state.categories.size }) + var skinPickerEmoji by remember { mutableStateOf(null) } + + val selectedSkinUnicodes = remember(skinPickerEmoji, selectedEmojis) { + if (skinPickerEmoji != null) { + val emoji = skinPickerEmoji!! + val variants = emoji.skins.orEmpty() + .map { it.unicode } + .filter { it in selectedEmojis } + if (variants.isNotEmpty()) { + variants.toSet() + } else if (emoji.unicode in selectedEmojis) { + setOf(emoji.unicode) + } else { + emptySet() + } + } else { + emptySet() + } + } + Column(modifier) { SearchBar( modifier = Modifier.padding(bottom = 10.dp), @@ -68,6 +92,11 @@ fun EmojiPicker( emojis = emojis, isEmojiSelected = { selectedEmojis.contains(it.unicode) }, onSelectEmoji = onSelectEmoji, + onLongPress = { skinPickerEmoji = it }, + skinPickerEmoji = skinPickerEmoji, + onDismissSkinPicker = { skinPickerEmoji = null }, + selectedEmojis = selectedEmojis, + selectedSkinUnicodes = selectedSkinUnicodes, ) } @@ -106,6 +135,11 @@ fun EmojiPicker( emojis = emojis, isEmojiSelected = { selectedEmojis.contains(it.unicode) }, onSelectEmoji = onSelectEmoji, + onLongPress = { skinPickerEmoji = it }, + skinPickerEmoji = skinPickerEmoji, + onDismissSkinPicker = { skinPickerEmoji = null }, + selectedEmojis = selectedEmojis, + selectedSkinUnicodes = selectedSkinUnicodes, ) } } @@ -117,6 +151,11 @@ private fun EmojiResults( emojis: ImmutableList, isEmojiSelected: (Emoji) -> Boolean, onSelectEmoji: (Emoji) -> Unit, + onLongPress: ((Emoji) -> Unit)? = null, + skinPickerEmoji: Emoji? = null, + onDismissSkinPicker: (() -> Unit)? = null, + selectedEmojis: ImmutableSet = persistentSetOf(), + selectedSkinUnicodes: Set = emptySet(), ) { LazyVerticalGrid( modifier = Modifier.fillMaxSize(), @@ -131,7 +170,12 @@ private fun EmojiResults( item = item, isSelected = isEmojiSelected(item), onSelectEmoji = onSelectEmoji, + onLongPress = onLongPress, + skinPickerEmoji = skinPickerEmoji, + onDismissSkinPicker = onDismissSkinPicker, emojiSize = 32.dp.toSp(), + selectedSkinUnicodes = selectedSkinUnicodes, + hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt new file mode 100644 index 0000000000..cdabf5772a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt @@ -0,0 +1,131 @@ +/* + * 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.features.messages.impl.timeline.components.customreaction.picker + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.emojibasebindings.Emoji +import io.element.android.emojibasebindings.EmojiSkin +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toSp +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.persistentListOf + +val SkinToneSlotSize = 48.dp +val SkinToneSlotSpacing = 2.dp +val SkinTonePadding = 4.dp + +@Composable +fun SkinTonePickerContent( + emoji: Emoji, + onSelect: (Emoji) -> Unit, + modifier: Modifier = Modifier, + hoveredIndex: Int = -1, + selectedUnicodes: Set = emptySet(), +) { + val skins = emoji.skins.orEmpty() + val emojiSize = 32.dp.toSp() + + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = ElementTheme.colors.bgCanvasDefault, + tonalElevation = 4.dp, + ) { + Row( + modifier = Modifier.padding(SkinTonePadding), + horizontalArrangement = Arrangement.spacedBy(SkinToneSlotSpacing), + ) { + SkinToneSlot( + unicode = emoji.unicode, + emojiSize = emojiSize, + isHovered = hoveredIndex == 0, + isSelected = emoji.unicode in selectedUnicodes, + onClick = { onSelect(emoji) }, + ) + skins.forEachIndexed { index, skin -> + SkinToneSlot( + unicode = skin.unicode, + emojiSize = emojiSize, + isHovered = hoveredIndex == index + 1, + isSelected = skin.unicode in selectedUnicodes, + onClick = { onSelect(emoji.copy(unicode = skin.unicode)) }, + ) + } + } + } +} + +@Composable +private fun SkinToneSlot( + unicode: String, + emojiSize: TextUnit, + isHovered: Boolean, + isSelected: Boolean, + onClick: () -> Unit, +) { + val background = when { + isSelected && isHovered -> ElementTheme.colors.bgActionPrimaryPressed + isSelected -> ElementTheme.colors.bgActionPrimaryRest + isHovered -> ElementTheme.colors.bgActionPrimaryHovered + else -> Color.Transparent + } + Box( + modifier = Modifier + .size(SkinToneSlotSize) + .clip(CircleShape) + .background(background, CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = unicode, + style = LocalTextStyle.current.copy(fontSize = emojiSize), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SkinTonePickerContentPreview() = ElementPreview { + SkinTonePickerContent( + emoji = Emoji( + hexcode = "1F44D", + label = "thumbs up", + tags = null, + shortcodes = persistentListOf("+1", "thumbsup"), + unicode = "👍", + skins = persistentListOf( + EmojiSkin("1F44D-1F3FB", "thumbs up: light skin tone", "👍🏻"), + EmojiSkin("1F44D-1F3FC", "thumbs up: medium-light skin tone", "👍🏼"), + EmojiSkin("1F44D-1F3FD", "thumbs up: medium skin tone", "👍🏽"), + EmojiSkin("1F44D-1F3FE", "thumbs up: medium-dark skin tone", "👍🏾"), + EmojiSkin("1F44D-1F3FF", "thumbs up: dark skin tone", "👍🏿"), + ), + ), + onSelect = {}, + ) +} From aeadd9879e43ec36f15a7d0df47c8b1affef193f Mon Sep 17 00:00:00 2001 From: ElementBot Date: Sat, 30 May 2026 14:48:33 +0000 Subject: [PATCH 02/64] Update screenshots --- ...ts.customreaction.picker_SkinTonePickerContent_Day_0_en.png | 3 +++ ....customreaction.picker_SkinTonePickerContent_Night_0_en.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Day_0_en.png new file mode 100644 index 0000000000..de4d90383b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0e294bf94727e3599cc94a80668dba83742f1897c55bf7476bbb4b4fb4116c1 +size 18794 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Night_0_en.png new file mode 100644 index 0000000000..9e526ca3cb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ab6d6139c2e15334980c753f97feedc688285a9704650e47956df502cadb6ef +size 18539 From 0e5a7e93965efa09727cdc7ee8f1d47716be932d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 1 Jun 2026 11:37:55 +0200 Subject: [PATCH 03/64] Fix selection behaviour when long pressing emojis with no skin tone variants Also improve the threshold calculation to dismiss the displayed skin tone picker a bit --- .../components/customreaction/EmojiItem.kt | 149 ++++++++++-------- 1 file changed, 81 insertions(+), 68 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index 4a8d2e6cca..b981055e61 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Fill @@ -48,7 +49,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.emojibasebindings.Emoji import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePadding -import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePickerContent +import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePicker import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSize import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSpacing import io.element.android.libraries.designsystem.preview.ElementPreview @@ -83,6 +84,7 @@ fun EmojiItem( emoji = item.unicode, userAlreadyReacted = isSelected, ) + val hasSkinTones = !item.skins.isNullOrEmpty() Box( modifier = modifier .sizeIn(minWidth = 40.dp, minHeight = 40.dp) @@ -90,12 +92,88 @@ fun EmojiItem( .background(backgroundColor, CircleShape) .indication(interactionSource, ripple()) .pointerInput(item) { + // Only detect drag after long press for those items which have a skin tone picker + if (hasSkinTones) { + var startOffset = Offset.Zero + detectDragGesturesAfterLongPress( + onDragStart = { position -> + if (onLongPress != null) { + startOffset = position + dismissed = false + onLongPress(item) + hoveredIndex = -1 + } + }, + onDrag = { change, _ -> + if (!dismissed) { + change.consume() + // It's a valid drag event if it's within ~2 items height above, so, on top of the current one or the skin tone selector, + // or 1 item below, which should be far enough to not be triggered by mistake + val isValidDrag = if (change.position.y < startOffset.y) { + startOffset.y - change.position.y <= itemSize.height * 2f + } else { + change.position.y - startOffset.y <= itemSize.height + } + if (!isValidDrag) { + dismissed = true + hoveredIndex = -1 + onDismissSkinPicker?.invoke() + } else { + val skinCount = item.skins!!.size + val slotWidthPx = with(density) { SkinToneSlotSize.toPx() } + val spacingPx = with(density) { SkinToneSlotSpacing.toPx() } + val paddingPx = with(density) { SkinTonePadding.toPx() } + val totalSlots = 1 + skinCount + val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx + val pickerHalfWidthPx = pickerWidthPx / 2f + val centerXPx = itemSize.width / 2f + val pickerLeftPx = centerXPx - pickerHalfWidthPx + val xInPicker = change.position.x - pickerLeftPx + val index = if (xInPicker in 0f..pickerWidthPx) { + (xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1) + } else { + -1 + } + hoveredIndex = index + } + } + }, + onDragEnd = { + if (!dismissed) { + val idx = hoveredIndex + val skinCount = item.skins?.size ?: 0 + if (idx in 0..skinCount) { + if (idx == 0) { + onSelectEmoji(item) + } else { + val skin = item.skins?.getOrNull(idx - 1) + if (skin != null) { + onSelectEmoji(item.copy(unicode = skin.unicode)) + } + } + } else if (idx < 0) { + onSelectEmoji(item) + } + } + dismissed = false + hoveredIndex = -1 + onDismissSkinPicker?.invoke() + }, + onDragCancel = { + dismissed = false + hoveredIndex = -1 + }, + ) + } + + // Always detect long press and tap gestures detectTapGestures( onPress = { pressOffset -> val press = PressInteraction.Press(pressOffset) interactionSource.emit(press) if (tryAwaitRelease()) { interactionSource.emit(PressInteraction.Release(press)) + onSelectEmoji(item) } else { interactionSource.emit(PressInteraction.Cancel(press)) } @@ -103,71 +181,6 @@ fun EmojiItem( onTap = { onSelectEmoji(item) }, ) } - .pointerInput(item) { - detectDragGesturesAfterLongPress( - onDragStart = { _ -> - if (item.skins != null && onLongPress != null) { - dismissed = false - onLongPress(item) - hoveredIndex = -1 - } - }, - onDrag = { change, _ -> - if (item.skins != null && !dismissed) { - change.consume() - val yThresholdPx = with(density) { 100.dp.toPx() } - if (change.position.y > yThresholdPx) { - dismissed = true - hoveredIndex = -1 - onDismissSkinPicker?.invoke() - } else { - val skinCount = item.skins!!.size - val slotWidthPx = with(density) { SkinToneSlotSize.toPx() } - val spacingPx = with(density) { SkinToneSlotSpacing.toPx() } - val paddingPx = with(density) { SkinTonePadding.toPx() } - val totalSlots = 1 + skinCount - val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx - val pickerHalfWidthPx = pickerWidthPx / 2f - val centerXPx = itemSize.width / 2f - val pickerLeftPx = centerXPx - pickerHalfWidthPx - val xInPicker = change.position.x - pickerLeftPx - val index = if (xInPicker >= 0f && xInPicker <= pickerWidthPx) { - (xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1) - } else { - -1 - } - hoveredIndex = index - } - } - }, - onDragEnd = { - if (!dismissed) { - val idx = hoveredIndex - val skinCount = item.skins?.size ?: 0 - if (idx in 0..skinCount) { - if (idx == 0) { - onSelectEmoji(item) - } else { - val skin = item.skins?.getOrNull(idx - 1) - if (skin != null) { - onSelectEmoji(item.copy(unicode = skin.unicode)) - } - } - } else if (idx < 0) { - onSelectEmoji(item) - } - } - dismissed = false - hoveredIndex = -1 - onDismissSkinPicker?.invoke() - }, - onDragCancel = { - dismissed = false - hoveredIndex = -1 - onDismissSkinPicker?.invoke() - }, - ) - } .clearAndSetSemantics { contentDescription = description }, @@ -177,7 +190,7 @@ fun EmojiItem( text = item.unicode, style = LocalTextStyle.current.copy(fontSize = emojiSize), ) - if (item.skins != null) { + if (hasSkinTones) { Canvas( modifier = Modifier .align(Alignment.BottomEnd) @@ -201,7 +214,7 @@ fun EmojiItem( alignment = Alignment.BottomCenter, offset = IntOffset(0, popupOffsetPx), ) { - SkinTonePickerContent( + SkinTonePicker( emoji = item, onSelect = { selectedEmoji -> onSelectEmoji(selectedEmoji) From 77dbd56e4e129e9420943c4c2bae2682e83da013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 1 Jun 2026 11:38:02 +0200 Subject: [PATCH 04/64] Fix Konsist lint issue --- .../components/customreaction/picker/SkinTonePicker.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt index cdabf5772a..7fdfbfb1a4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt @@ -39,7 +39,7 @@ val SkinToneSlotSpacing = 2.dp val SkinTonePadding = 4.dp @Composable -fun SkinTonePickerContent( +fun SkinTonePicker( emoji: Emoji, onSelect: (Emoji) -> Unit, modifier: Modifier = Modifier, @@ -110,8 +110,8 @@ private fun SkinToneSlot( @PreviewsDayNight @Composable -internal fun SkinTonePickerContentPreview() = ElementPreview { - SkinTonePickerContent( +internal fun SkinTonePickerPreview() = ElementPreview { + SkinTonePicker( emoji = Emoji( hexcode = "1F44D", label = "thumbs up", From bfcce0e94bf40179af2f02394ab6f873ab87f08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 1 Jun 2026 11:38:35 +0200 Subject: [PATCH 05/64] Tweak Konsist check to allow `LongPress` actions --- .../element/android/tests/konsist/KonsistParameterNameTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistParameterNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistParameterNameTest.kt index f202148464..752acb2864 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistParameterNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistParameterNameTest.kt @@ -19,7 +19,7 @@ class KonsistParameterNameTest { Konsist.scopeFromProject() .functions() .withParameter { parameter -> - parameter.name.endsWith("Press") + parameter.name.endsWith("Press") && !parameter.name.endsWith("LongPress") } .assertEmpty(additionalMessage = "Please rename the parameter, for instance from 'onBackPress' to 'onBackClick'.") } From cd64b5e3763a1f6e0c95851525c9184185a0359b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 2 Jun 2026 11:50:26 +0200 Subject: [PATCH 06/64] Remove apparently unneeded `selectedSkinUnicodes` --- .../customreaction/picker/EmojiPicker.kt | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt index a0cd4928fc..05fb849ce9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt @@ -60,24 +60,6 @@ fun EmojiPicker( val pagerState = rememberPagerState(pageCount = { state.categories.size }) var skinPickerEmoji by remember { mutableStateOf(null) } - val selectedSkinUnicodes = remember(skinPickerEmoji, selectedEmojis) { - if (skinPickerEmoji != null) { - val emoji = skinPickerEmoji!! - val variants = emoji.skins.orEmpty() - .map { it.unicode } - .filter { it in selectedEmojis } - if (variants.isNotEmpty()) { - variants.toSet() - } else if (emoji.unicode in selectedEmojis) { - setOf(emoji.unicode) - } else { - emptySet() - } - } else { - emptySet() - } - } - Column(modifier) { SearchBar( modifier = Modifier.padding(bottom = 10.dp), @@ -96,7 +78,6 @@ fun EmojiPicker( skinPickerEmoji = skinPickerEmoji, onDismissSkinPicker = { skinPickerEmoji = null }, selectedEmojis = selectedEmojis, - selectedSkinUnicodes = selectedSkinUnicodes, ) } @@ -139,7 +120,6 @@ fun EmojiPicker( skinPickerEmoji = skinPickerEmoji, onDismissSkinPicker = { skinPickerEmoji = null }, selectedEmojis = selectedEmojis, - selectedSkinUnicodes = selectedSkinUnicodes, ) } } @@ -155,7 +135,6 @@ private fun EmojiResults( skinPickerEmoji: Emoji? = null, onDismissSkinPicker: (() -> Unit)? = null, selectedEmojis: ImmutableSet = persistentSetOf(), - selectedSkinUnicodes: Set = emptySet(), ) { LazyVerticalGrid( modifier = Modifier.fillMaxSize(), @@ -174,7 +153,7 @@ private fun EmojiResults( skinPickerEmoji = skinPickerEmoji, onDismissSkinPicker = onDismissSkinPicker, emojiSize = 32.dp.toSp(), - selectedSkinUnicodes = selectedSkinUnicodes, + selectedSkinUnicodes = selectedEmojis, hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true, ) } From 5e3b1463d477063be563e4ba6e5bc1b4c4b99be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 2 Jun 2026 12:55:23 +0200 Subject: [PATCH 07/64] Fix skin tone picker always selecting the middle item by default instead of based on the current pointer position --- .../components/customreaction/EmojiItem.kt | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index b981055e61..ee5055176c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -25,6 +25,8 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -35,7 +37,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -77,7 +81,8 @@ fun EmojiItem( } val density = LocalDensity.current var itemSize by remember { mutableStateOf(IntSize.Zero) } - var hoveredIndex by remember { mutableStateOf(-1) } + var itemOffsetPercent by remember { mutableFloatStateOf(0f) } + var hoveredIndex by remember { mutableIntStateOf(-1) } var dismissed by remember { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } val description = a11yReactionAction( @@ -87,6 +92,17 @@ fun EmojiItem( val hasSkinTones = !item.skins.isNullOrEmpty() Box( modifier = modifier + .onPlaced { coordinates -> + val position = coordinates.positionInParent() + val parentBounds = coordinates.parentLayoutCoordinates?.size ?: return@onPlaced + // Calculate the offset of the item inside its parent as a percentage, to be able to set the initial offset + // for the selection inside the skin tone picker relative to its size. + itemOffsetPercent = if (parentBounds.width > 0) { + position.x / parentBounds.width + } else { + 0f + } + } .sizeIn(minWidth = 40.dp, minHeight = 40.dp) .onSizeChanged { itemSize = it } .background(backgroundColor, CircleShape) @@ -94,6 +110,9 @@ fun EmojiItem( .pointerInput(item) { // Only detect drag after long press for those items which have a skin tone picker if (hasSkinTones) { + val slotWidthPx = with(density) { SkinToneSlotSize.toPx() } + val spacingPx = with(density) { SkinToneSlotSpacing.toPx() } + val paddingPx = with(density) { SkinTonePadding.toPx() } var startOffset = Offset.Zero detectDragGesturesAfterLongPress( onDragStart = { position -> @@ -119,16 +138,16 @@ fun EmojiItem( hoveredIndex = -1 onDismissSkinPicker?.invoke() } else { - val skinCount = item.skins!!.size - val slotWidthPx = with(density) { SkinToneSlotSize.toPx() } - val spacingPx = with(density) { SkinToneSlotSpacing.toPx() } - val paddingPx = with(density) { SkinTonePadding.toPx() } - val totalSlots = 1 + skinCount + val skinItemsCount = item.skins!!.size + // Original + variants + val totalSlots = 1 + skinItemsCount + // Calculate the whole size of the skin tone picker val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx - val pickerHalfWidthPx = pickerWidthPx / 2f - val centerXPx = itemSize.width / 2f - val pickerLeftPx = centerXPx - pickerHalfWidthPx - val xInPicker = change.position.x - pickerLeftPx + // Calculate the initial offset inside the picker, given the relative position of the item inside its parent + val initialOffset = pickerWidthPx * itemOffsetPercent + val xInPicker = initialOffset + change.position.x + + // If it's a valid offset, calculate the hovered index, otherwise, set it to -1 to indicate that no item is hovered val index = if (xInPicker in 0f..pickerWidthPx) { (xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1) } else { From 88d419c03fa3badc9a46029086c6a7476b23575a Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:06:17 +0000 Subject: [PATCH 08/64] Revert "Send SVG images as files (#4595)" --- .../io/element/android/libraries/core/mimetype/MimeTypes.kt | 1 - .../libraries/mediaupload/impl/AndroidMediaPreProcessor.kt | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index e7962f8bd6..73c05ffcef 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -26,7 +26,6 @@ object MimeTypes { const val Jpeg = "image/jpeg" const val Gif = "image/gif" const val WebP = "image/webp" - const val Svg = "image/svg+xml" const val Videos = "video/*" const val Mp4 = "video/mp4" diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 2ab1cd0619..8c5bab459f 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -67,7 +67,7 @@ class AndroidMediaPreProcessor( */ private const val IMAGE_SCALE_REF_SIZE = 640 - private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg) + private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP) } private val contentResolver = context.contentResolver @@ -83,10 +83,6 @@ class AndroidMediaPreProcessor( ): Result = withContext(coroutineDispatchers.computation) { runCatchingExceptions { val result = when { - // Special case for SVG, since Android can't read its metadata or create a thumbnail, it must be sent as a file - mimeType == MimeTypes.Svg -> { - processFile(uri, mimeType) - } mimeType.isMimeTypeImage() -> { val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes processImage(uri, mimeType, shouldBeCompressed) From 5b53686c3e1ab23d04f71ce721b32a97e017e754 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:06:17 +0000 Subject: [PATCH 09/64] Add full SVG support --- gradle/libs.versions.toml | 1 + .../libraries/core/mimetype/MimeTypes.kt | 1 + .../matrix/api/exception/ClientException.kt | 3 + .../matrix/impl/media/RustMediaLoader.kt | 6 +- libraries/matrixmedia/impl/build.gradle.kts | 1 + .../matrix/ui/media/CoilMediaFetcher.kt | 19 ++++- .../matrix/ui/media/ImageLoaderFactories.kt | 4 + .../impl/AndroidMediaPreProcessor.kt | 82 ++++++++++++++++++- .../impl/src/test/assets/image.svg | 3 + .../impl/AndroidMediaPreProcessorTest.kt | 48 +++++++++++ .../libraries/mediaupload/impl/Asset.kt | 11 +++ 11 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 libraries/mediaupload/impl/src/test/assets/image.svg diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 163a583597..2cdda307c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -190,6 +190,7 @@ coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil_network_okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } +coil_svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index 73c05ffcef..e7962f8bd6 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -26,6 +26,7 @@ object MimeTypes { const val Jpeg = "image/jpeg" const val Gif = "image/gif" const val WebP = "image/webp" + const val Svg = "image/svg+xml" const val Videos = "video/*" const val Mp4 = "video/mp4" diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt index 565dc59614..3a1f0825f4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt @@ -22,3 +22,6 @@ sealed class ClientException(message: String, val details: String?, cause: Throw fun ClientException.isNetworkError(): Boolean { return this is ClientException.Generic && message?.contains("error sending request for url", ignoreCase = true) == true } + +fun Throwable.isNetworkError(): Boolean = + (this as? ClientException)?.isNetworkError() == true diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index 81946980e3..d1401d2624 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -9,11 +9,13 @@ package io.element.android.libraries.matrix.impl.media import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.impl.exception.mapClientException import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.use @@ -37,7 +39,7 @@ class RustMediaLoader( source.toRustMediaSource().use { source -> innerClient.getMediaContent(source) } - } + }.mapFailure { it.mapClientException() } } override suspend fun loadMediaThumbnail( @@ -54,7 +56,7 @@ class RustMediaLoader( height = height.toULong() ) } - } + }.mapFailure { it.mapClientException() } } override suspend fun downloadMediaFile( diff --git a/libraries/matrixmedia/impl/build.gradle.kts b/libraries/matrixmedia/impl/build.gradle.kts index 56ccc79afc..62799b5eaa 100644 --- a/libraries/matrixmedia/impl/build.gradle.kts +++ b/libraries/matrixmedia/impl/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(libs.coil.compose) implementation(libs.coil.gif) + implementation(libs.coil.svg) implementation(libs.coil.network.okhttp) testCommonDependencies(libs, true) diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index 39f67b41d7..b1f72976e0 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -13,6 +13,7 @@ import coil3.decode.ImageSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.SourceFetchResult +import io.element.android.libraries.matrix.api.exception.isNetworkError import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.toFile @@ -34,7 +35,19 @@ internal class CoilMediaFetcher( } return when (val kind = mediaData.kind) { is MediaRequestData.Kind.Content -> fetchContent(mediaSource) - is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaSource, kind) + is MediaRequestData.Kind.Thumbnail -> { + val thumbnailResult = fetchThumbnail(mediaSource, kind) + if (thumbnailResult.isSuccess) { + thumbnailResult.getOrThrow() + } else { + val error = thumbnailResult.exceptionOrNull() + if (error?.isNetworkError() == true) { + null + } else { + fetchContent(mediaSource) + } + } + } is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind) } } @@ -74,7 +87,7 @@ internal class CoilMediaFetcher( }.getOrNull() } - private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): FetchResult? { + private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result { return mediaLoader.loadMediaThumbnail( source = mediaSource, width = kind.width, @@ -83,7 +96,7 @@ internal class CoilMediaFetcher( byteArray.asSourceResult() }.onFailure { Timber.e(it) - }.getOrNull() + } } private fun ByteArray.asSourceResult(): SourceFetchResult { diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt index ef654a4cf7..c74a819ecd 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt @@ -14,6 +14,7 @@ import coil3.ImageLoader import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.svg.SvgDecoder import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.di.annotations.ApplicationContext @@ -41,6 +42,7 @@ class DefaultImageLoaderFactory( return ImageLoader.Builder(context) .components { add(okHttpNetworkFetcherFactory) + add(SvgDecoder.Factory()) } .build() } @@ -49,6 +51,8 @@ class DefaultImageLoaderFactory( return ImageLoader.Builder(context) .components { add(okHttpNetworkFetcherFactory) + // Add svg support + add(SvgDecoder.Factory()) // Add gif support if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { add(AnimatedImageDecoder.Factory()) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 8c5bab459f..11e5e7e65e 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -12,6 +12,7 @@ import android.content.Context import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri +import android.util.Xml import androidx.exifinterface.media.ExifInterface import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -42,6 +43,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext +import org.xmlpull.v1.XmlPullParser import timber.log.Timber import java.io.File import java.io.InputStream @@ -67,7 +69,10 @@ class AndroidMediaPreProcessor( */ private const val IMAGE_SCALE_REF_SIZE = 640 - private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP) + private const val SVG_DEFAULT_WIDTH = 640L + private const val SVG_DEFAULT_HEIGHT = 480L + + private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg) } private val contentResolver = context.contentResolver @@ -156,6 +161,11 @@ class AndroidMediaPreProcessor( private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { Timber.d("Processing image ${uri.path.orEmpty().hash()}") + + if (mimeType == MimeTypes.Svg) { + return processSvgImage(uri, mimeType) + } + suspend fun processImageWithCompression(): MediaUploadInfo { // Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail. val orientation = contentResolver.openInputStream(uri).use { input -> @@ -216,6 +226,76 @@ class AndroidMediaPreProcessor( } } + private suspend fun processSvgImage(uri: Uri, mimeType: String): MediaUploadInfo { + Timber.d("Processing SVG image ${uri.path.orEmpty().hash()}") + val file = copyToTmpFile(uri) + val (width, height) = extractSvgDimensions(file) + val imageInfo = ImageInfo( + width = width, + height = height, + mimetype = mimeType, + size = file.length(), + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + return MediaUploadInfo.Image( + file = file, + imageInfo = imageInfo, + thumbnailFile = null, + ) + } + + private fun extractSvgDimensions(file: File): Pair { + return file.inputStream().use { inputStream -> + try { + val parser = Xml.newPullParser() + parser.setInput(inputStream, null) + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.name.equals("svg", ignoreCase = true)) { + val width = parser.getAttributeValue(null, "width") + val height = parser.getAttributeValue(null, "height") + val viewBox = parser.getAttributeValue(null, "viewBox") + + val parsedWidth = width?.let { parseSvgLength(it) } + val parsedHeight = height?.let { parseSvgLength(it) } + + if (parsedWidth != null && parsedHeight != null) { + return parsedWidth to parsedHeight + } + + if (viewBox != null) { + val parts = viewBox.trim().split("\\s+".toRegex()).map { it.toFloatOrNull() } + if (parts.size == 4 && parts[2] != null && parts[3] != null) { + val vbWidth = parts[2]!!.toLong().coerceAtLeast(1) + val vbHeight = parts[3]!!.toLong().coerceAtLeast(1) + return vbWidth to vbHeight + } + } + + return (parsedWidth ?: SVG_DEFAULT_WIDTH) to (parsedHeight ?: SVG_DEFAULT_HEIGHT) + } + eventType = parser.next() + } + SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT + } catch (_: Exception) { + SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT + } + } + } + + private fun parseSvgLength(value: String): Long? { + val trimmed = value.trim() + if (trimmed.isEmpty()) return null + return try { + val numericPart = trimmed.replace(Regex("[^\\d.]"), "") + if (numericPart.isEmpty()) null else numericPart.toFloat().toLong().coerceAtLeast(1) + } catch (_: NumberFormatException) { + null + } + } + private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo { Timber.d("Processing video ${uri.path.orEmpty().hash()}") val resultFile = runCatchingExceptions { diff --git a/libraries/mediaupload/impl/src/test/assets/image.svg b/libraries/mediaupload/impl/src/test/assets/image.svg new file mode 100644 index 0000000000..03a3fb275b --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/image.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97a22915f50ba62605a8e3f6ba8e8099c1d601d2aa929c2879197b41531ce4dc +size 210 diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index 57726ac5a2..cf82f4f7ae 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -401,6 +401,54 @@ class AndroidMediaPreProcessorTest { ) } + @Test + fun `test processing svg`() = runTest { + val mediaUploadInfo = process( + asset = assetImageSvg, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + width = 800, + height = 600, + mimetype = MimeTypes.Svg, + size = 210, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + assertThat(info.thumbnailFile).isNull() + } + + @Test + fun `test processing svg without compression`() = runTest { + val mediaUploadInfo = process( + asset = assetImageSvg, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + width = 800, + height = 600, + mimetype = MimeTypes.Svg, + size = 210, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + assertThat(info.thumbnailFile).isNull() + } + @Test fun `test processing audio`() = runTest { val mediaUploadInfo = process( diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt index 4dad31a784..7b70244852 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt @@ -83,3 +83,14 @@ val assetAnimatedGif = Asset( width = 800, height = 600, ) + +/** + * "image.svg" is an 800 x 600 SVG image with a size of 210 bytes. + */ +val assetImageSvg = Asset( + filename = "image.svg", + mimeType = MimeTypes.Svg, + size = 210, + width = 800, + height = 600, +) From 35dce84c7fe7e7fa86479d8420a0bc7597f3ac92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 22 Jun 2026 09:17:15 +0200 Subject: [PATCH 10/64] Fix lint issues --- .../impl/timeline/components/customreaction/EmojiItem.kt | 4 +++- .../components/customreaction/picker/SkinTonePicker.kt | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index ee5055176c..7ea023fe40 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -59,7 +59,9 @@ import io.element.android.features.messages.impl.timeline.components.customreact import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf @Composable fun EmojiItem( @@ -71,7 +73,7 @@ fun EmojiItem( onLongPress: ((Emoji) -> Unit)? = null, skinPickerEmoji: Emoji? = null, onDismissSkinPicker: (() -> Unit)? = null, - selectedSkinUnicodes: Set = emptySet(), + selectedSkinUnicodes: ImmutableSet = persistentSetOf(), hasSelectedSkin: Boolean = false, ) { val backgroundColor = when { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt index 7fdfbfb1a4..c49b07023f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt @@ -32,7 +32,9 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toSp import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf val SkinToneSlotSize = 48.dp val SkinToneSlotSpacing = 2.dp @@ -44,7 +46,7 @@ fun SkinTonePicker( onSelect: (Emoji) -> Unit, modifier: Modifier = Modifier, hoveredIndex: Int = -1, - selectedUnicodes: Set = emptySet(), + selectedUnicodes: ImmutableSet = persistentSetOf(), ) { val skins = emoji.skins.orEmpty() val emojiSize = 32.dp.toSp() From 0bda1cd0d02d935f5d61700047ce48d10c89c763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 22 Jun 2026 12:03:00 +0200 Subject: [PATCH 11/64] Fix double selection of emoji when long-pressing one that doesn't have skin variants --- .../impl/timeline/components/customreaction/EmojiItem.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index 7ea023fe40..2dcffe10c8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -194,7 +194,6 @@ fun EmojiItem( interactionSource.emit(press) if (tryAwaitRelease()) { interactionSource.emit(PressInteraction.Release(press)) - onSelectEmoji(item) } else { interactionSource.emit(PressInteraction.Cancel(press)) } From b3b8467636002d1fa37689becbbbebeee944c61d Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 22 Jun 2026 12:36:29 +0000 Subject: [PATCH 12/64] Update screenshots --- ....components.customreaction.picker_SkinTonePicker_Day_0_en.png} | 0 ...omponents.customreaction.picker_SkinTonePicker_Night_0_en.png} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Day_0_en.png => features.messages.impl.timeline.components.customreaction.picker_SkinTonePicker_Day_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Night_0_en.png => features.messages.impl.timeline.components.customreaction.picker_SkinTonePicker_Night_0_en.png} (100%) diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePicker_Day_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePicker_Day_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePicker_Night_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePickerContent_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction.picker_SkinTonePicker_Night_0_en.png From c4c0c482f0c6a9620a3fd62466fe870b46b68ca8 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:02:15 +0000 Subject: [PATCH 13/64] Address review --- .../matrix/ui/media/CoilMediaFetcher.kt | 19 +++--- .../matrix/ui/media/ImageLoaderFactories.kt | 1 - .../impl/AndroidMediaPreProcessor.kt | 66 ++---------------- .../mediaupload/impl/SvgDimensionExtractor.kt | 68 +++++++++++++++++++ 4 files changed, 82 insertions(+), 72 deletions(-) create mode 100644 libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index b1f72976e0..713057ecda 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -36,17 +36,16 @@ internal class CoilMediaFetcher( return when (val kind = mediaData.kind) { is MediaRequestData.Kind.Content -> fetchContent(mediaSource) is MediaRequestData.Kind.Thumbnail -> { - val thumbnailResult = fetchThumbnail(mediaSource, kind) - if (thumbnailResult.isSuccess) { - thumbnailResult.getOrThrow() - } else { - val error = thumbnailResult.exceptionOrNull() - if (error?.isNetworkError() == true) { - null - } else { - fetchContent(mediaSource) + fetchThumbnail(mediaSource, kind).fold( + onSuccess = { it }, + onFailure = { error -> + if (error.isNetworkError()) { + null + } else { + fetchContent(mediaSource) + } } - } + ) } is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind) } diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt index c74a819ecd..b414560fc9 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt @@ -42,7 +42,6 @@ class DefaultImageLoaderFactory( return ImageLoader.Builder(context) .components { add(okHttpNetworkFetcherFactory) - add(SvgDecoder.Factory()) } .build() } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 11e5e7e65e..406f4e89bf 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -12,7 +12,6 @@ import android.content.Context import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri -import android.util.Xml import androidx.exifinterface.media.ExifInterface import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -43,7 +42,6 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext -import org.xmlpull.v1.XmlPullParser import timber.log.Timber import java.io.File import java.io.InputStream @@ -69,10 +67,7 @@ class AndroidMediaPreProcessor( */ private const val IMAGE_SCALE_REF_SIZE = 640 - private const val SVG_DEFAULT_WIDTH = 640L - private const val SVG_DEFAULT_HEIGHT = 480L - - private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg) + private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP) } private val contentResolver = context.contentResolver @@ -88,6 +83,7 @@ class AndroidMediaPreProcessor( ): Result = withContext(coroutineDispatchers.computation) { runCatchingExceptions { val result = when { + mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType) mimeType.isMimeTypeImage() -> { val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes processImage(uri, mimeType, shouldBeCompressed) @@ -162,10 +158,6 @@ class AndroidMediaPreProcessor( private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { Timber.d("Processing image ${uri.path.orEmpty().hash()}") - if (mimeType == MimeTypes.Svg) { - return processSvgImage(uri, mimeType) - } - suspend fun processImageWithCompression(): MediaUploadInfo { // Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail. val orientation = contentResolver.openInputStream(uri).use { input -> @@ -226,10 +218,12 @@ class AndroidMediaPreProcessor( } } + private val svgDimensionExtractor = SvgDimensionExtractor() + private suspend fun processSvgImage(uri: Uri, mimeType: String): MediaUploadInfo { Timber.d("Processing SVG image ${uri.path.orEmpty().hash()}") val file = copyToTmpFile(uri) - val (width, height) = extractSvgDimensions(file) + val (width, height) = svgDimensionExtractor.extractDimensions(file) val imageInfo = ImageInfo( width = width, height = height, @@ -246,56 +240,6 @@ class AndroidMediaPreProcessor( ) } - private fun extractSvgDimensions(file: File): Pair { - return file.inputStream().use { inputStream -> - try { - val parser = Xml.newPullParser() - parser.setInput(inputStream, null) - var eventType = parser.eventType - while (eventType != XmlPullParser.END_DOCUMENT) { - if (eventType == XmlPullParser.START_TAG && parser.name.equals("svg", ignoreCase = true)) { - val width = parser.getAttributeValue(null, "width") - val height = parser.getAttributeValue(null, "height") - val viewBox = parser.getAttributeValue(null, "viewBox") - - val parsedWidth = width?.let { parseSvgLength(it) } - val parsedHeight = height?.let { parseSvgLength(it) } - - if (parsedWidth != null && parsedHeight != null) { - return parsedWidth to parsedHeight - } - - if (viewBox != null) { - val parts = viewBox.trim().split("\\s+".toRegex()).map { it.toFloatOrNull() } - if (parts.size == 4 && parts[2] != null && parts[3] != null) { - val vbWidth = parts[2]!!.toLong().coerceAtLeast(1) - val vbHeight = parts[3]!!.toLong().coerceAtLeast(1) - return vbWidth to vbHeight - } - } - - return (parsedWidth ?: SVG_DEFAULT_WIDTH) to (parsedHeight ?: SVG_DEFAULT_HEIGHT) - } - eventType = parser.next() - } - SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT - } catch (_: Exception) { - SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT - } - } - } - - private fun parseSvgLength(value: String): Long? { - val trimmed = value.trim() - if (trimmed.isEmpty()) return null - return try { - val numericPart = trimmed.replace(Regex("[^\\d.]"), "") - if (numericPart.isEmpty()) null else numericPart.toFloat().toLong().coerceAtLeast(1) - } catch (_: NumberFormatException) { - null - } - } - private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo { Timber.d("Processing video ${uri.path.orEmpty().hash()}") val resultFile = runCatchingExceptions { diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt new file mode 100644 index 0000000000..5ef756a904 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector 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.mediaupload.impl + +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import java.io.File + +class SvgDimensionExtractor( + private val defaultWidth: Long = 640L, + private val defaultHeight: Long = 480L, +) { + fun extractDimensions(file: File): Pair { + return file.inputStream().use { inputStream -> + try { + val parser = Xml.newPullParser() + parser.setInput(inputStream, null) + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.name.equals("svg", ignoreCase = true)) { + val width = parser.getAttributeValue(null, "width") + val height = parser.getAttributeValue(null, "height") + val viewBox = parser.getAttributeValue(null, "viewBox") + + val parsedWidth = width?.let { parseLength(it) } + val parsedHeight = height?.let { parseLength(it) } + + if (parsedWidth != null && parsedHeight != null) { + return parsedWidth to parsedHeight + } + + if (viewBox != null) { + val parts = viewBox.trim().split("\\s+".toRegex()).map { it.toFloatOrNull() } + if (parts.size == 4 && parts[2] != null && parts[3] != null) { + val vbWidth = parts[2]!!.toLong().coerceAtLeast(1) + val vbHeight = parts[3]!!.toLong().coerceAtLeast(1) + return vbWidth to vbHeight + } + } + + return (parsedWidth ?: defaultWidth) to (parsedHeight ?: defaultHeight) + } + eventType = parser.next() + } + defaultWidth to defaultHeight + } catch (_: Exception) { + defaultWidth to defaultHeight + } + } + } + + private fun parseLength(value: String): Long? { + val trimmed = value.trim() + if (trimmed.isEmpty()) return null + return try { + val numericPart = trimmed.replace(Regex("[^\\d.]"), "") + if (numericPart.isEmpty()) null else numericPart.toFloat().toLong().coerceAtLeast(1) + } catch (_: NumberFormatException) { + null + } + } +} From 2902549a88f2842614b035c9c5d9fc5eed92152a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 23 Jun 2026 17:14:08 +0200 Subject: [PATCH 14/64] Fix wrong selection behaviour --- .../components/customreaction/EmojiItem.kt | 162 +++++++++--------- 1 file changed, 85 insertions(+), 77 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index 2dcffe10c8..d934f53bfb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -110,83 +110,6 @@ fun EmojiItem( .background(backgroundColor, CircleShape) .indication(interactionSource, ripple()) .pointerInput(item) { - // Only detect drag after long press for those items which have a skin tone picker - if (hasSkinTones) { - val slotWidthPx = with(density) { SkinToneSlotSize.toPx() } - val spacingPx = with(density) { SkinToneSlotSpacing.toPx() } - val paddingPx = with(density) { SkinTonePadding.toPx() } - var startOffset = Offset.Zero - detectDragGesturesAfterLongPress( - onDragStart = { position -> - if (onLongPress != null) { - startOffset = position - dismissed = false - onLongPress(item) - hoveredIndex = -1 - } - }, - onDrag = { change, _ -> - if (!dismissed) { - change.consume() - // It's a valid drag event if it's within ~2 items height above, so, on top of the current one or the skin tone selector, - // or 1 item below, which should be far enough to not be triggered by mistake - val isValidDrag = if (change.position.y < startOffset.y) { - startOffset.y - change.position.y <= itemSize.height * 2f - } else { - change.position.y - startOffset.y <= itemSize.height - } - if (!isValidDrag) { - dismissed = true - hoveredIndex = -1 - onDismissSkinPicker?.invoke() - } else { - val skinItemsCount = item.skins!!.size - // Original + variants - val totalSlots = 1 + skinItemsCount - // Calculate the whole size of the skin tone picker - val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx - // Calculate the initial offset inside the picker, given the relative position of the item inside its parent - val initialOffset = pickerWidthPx * itemOffsetPercent - val xInPicker = initialOffset + change.position.x - - // If it's a valid offset, calculate the hovered index, otherwise, set it to -1 to indicate that no item is hovered - val index = if (xInPicker in 0f..pickerWidthPx) { - (xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1) - } else { - -1 - } - hoveredIndex = index - } - } - }, - onDragEnd = { - if (!dismissed) { - val idx = hoveredIndex - val skinCount = item.skins?.size ?: 0 - if (idx in 0..skinCount) { - if (idx == 0) { - onSelectEmoji(item) - } else { - val skin = item.skins?.getOrNull(idx - 1) - if (skin != null) { - onSelectEmoji(item.copy(unicode = skin.unicode)) - } - } - } else if (idx < 0) { - onSelectEmoji(item) - } - } - dismissed = false - hoveredIndex = -1 - onDismissSkinPicker?.invoke() - }, - onDragCancel = { - dismissed = false - hoveredIndex = -1 - }, - ) - } - // Always detect long press and tap gestures detectTapGestures( onPress = { pressOffset -> @@ -194,6 +117,9 @@ fun EmojiItem( interactionSource.emit(press) if (tryAwaitRelease()) { interactionSource.emit(PressInteraction.Release(press)) + if (hasSkinTones) { + onSelectEmoji(item) + } } else { interactionSource.emit(PressInteraction.Cancel(press)) } @@ -201,6 +127,88 @@ fun EmojiItem( onTap = { onSelectEmoji(item) }, ) } + .then( + // Only detect drag after long press for those items which have a skin tone picker + if (hasSkinTones) { + Modifier.pointerInput(item) { + val slotWidthPx = with(density) { SkinToneSlotSize.toPx() } + val spacingPx = with(density) { SkinToneSlotSpacing.toPx() } + val paddingPx = with(density) { SkinTonePadding.toPx() } + var startOffset = Offset.Zero + detectDragGesturesAfterLongPress( + onDragStart = { position -> + if (onLongPress != null) { + startOffset = position + dismissed = false + onLongPress(item) + hoveredIndex = -1 + } + }, + onDrag = { change, _ -> + if (!dismissed) { + change.consume() + // It's a valid drag event if it's within ~2 items height above, so, on top of the current one or the skin tone selector, + // or 1 item below, which should be far enough to not be triggered by mistake + val isValidDrag = if (change.position.y < startOffset.y) { + startOffset.y - change.position.y <= itemSize.height * 2f + } else { + change.position.y - startOffset.y <= itemSize.height + } + if (!isValidDrag) { + dismissed = true + hoveredIndex = -1 + onDismissSkinPicker?.invoke() + } else { + val skinItemsCount = item.skins!!.size + // Original + variants + val totalSlots = 1 + skinItemsCount + // Calculate the whole size of the skin tone picker + val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx + // Calculate the initial offset inside the picker, given the relative position of the item inside its parent + val initialOffset = pickerWidthPx * itemOffsetPercent + val xInPicker = initialOffset + change.position.x + + // If it's a valid offset, calculate the hovered index, otherwise, set it to -1 to indicate that no item is hovered + val index = if (xInPicker in 0f..pickerWidthPx) { + (xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1) + } else { + -1 + } + hoveredIndex = index + } + } + }, + onDragEnd = { + if (!dismissed) { + val idx = hoveredIndex + val skinCount = item.skins?.size ?: 0 + if (idx in 0..skinCount) { + if (idx == 0) { + onSelectEmoji(item) + } else { + val skin = item.skins?.getOrNull(idx - 1) + if (skin != null) { + onSelectEmoji(item.copy(unicode = skin.unicode)) + } + } + } else if (idx < 0) { + onSelectEmoji(item) + } + } + dismissed = false + hoveredIndex = -1 + onDismissSkinPicker?.invoke() + }, + onDragCancel = { + dismissed = false + hoveredIndex = -1 + }, + ) + } + } else { + Modifier + } + ) .clearAndSetSemantics { contentDescription = description }, From bad32cbbda7dbcba7ddb22f3ddaf10e276d85c1f Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:47:21 +0200 Subject: [PATCH 15/64] Add UserStatus and DisplayedStatus types to matrix API --- .../libraries/matrix/api/MatrixClient.kt | 3 +++ .../matrix/api/user/DisplayedStatus.kt | 21 +++++++++++++++++++ .../libraries/matrix/api/user/MatrixUser.kt | 2 ++ .../libraries/matrix/api/user/UserStatus.kt | 17 +++++++++++++++ 4 files changed, 43 insertions(+) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/UserStatus.kt diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 8f482440e8..4147333565 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.verification.SessionVerificationService import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope @@ -84,6 +85,8 @@ interface MatrixClient { suspend fun setDisplayName(displayName: String): Result suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result suspend fun removeAvatar(): Result + suspend fun setUserStatus(status: UserStatus): Result + suspend fun clearUserStatus(): Result suspend fun joinRoom(roomId: RoomId): Result suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt new file mode 100644 index 0000000000..1b1dd551ff --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt @@ -0,0 +1,21 @@ +/* + * 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.user + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed interface DisplayedStatus : Parcelable { + /** Status set manually by the user via m.status. */ + @Parcelize + data class UserSet(val status: UserStatus) : DisplayedStatus + + /** Status set automatically when the user is in a call via m.call. */ + @Parcelize + data class InCall(val callJoinedTs: Long) : DisplayedStatus +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt index 53cefa3694..7a8ec739a5 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt @@ -17,4 +17,6 @@ data class MatrixUser( val userId: UserId, val displayName: String? = null, val avatarUrl: String? = null, + val displayedStatus: DisplayedStatus? = null, + val rawStatus: UserStatus? = null, ) : Parcelable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/UserStatus.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/UserStatus.kt new file mode 100644 index 0000000000..1576940b6f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/UserStatus.kt @@ -0,0 +1,17 @@ +/* + * 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.user + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class UserStatus( + val emoji: String, + val text: String, +) : Parcelable From 5697c9664f58a6194ce47f099d5b80d8b70e2c5b Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:49:57 +0200 Subject: [PATCH 16/64] Add KDoc to InCall.callJoinedTs and clearUserStatus --- .../io/element/android/libraries/matrix/api/MatrixClient.kt | 1 + .../android/libraries/matrix/api/user/DisplayedStatus.kt | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 4147333565..001bac0d30 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -86,6 +86,7 @@ interface MatrixClient { suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result suspend fun removeAvatar(): Result suspend fun setUserStatus(status: UserStatus): Result + /** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */ suspend fun clearUserStatus(): Result suspend fun joinRoom(roomId: RoomId): Result suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt index 1b1dd551ff..046191d7cf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt @@ -17,5 +17,8 @@ sealed interface DisplayedStatus : Parcelable { /** Status set automatically when the user is in a call via m.call. */ @Parcelize - data class InCall(val callJoinedTs: Long) : DisplayedStatus + data class InCall( + /** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */ + val callJoinedTs: Long, + ) : DisplayedStatus } From 742902ef02e753fe4e051b3aaa0f58dc1820c448 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:52:51 +0200 Subject: [PATCH 17/64] Add setUserStatus/clearUserStatus stubs to FakeMatrixClient --- .../libraries/matrix/test/FakeMatrixClient.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index cce99e9d09..dce089537f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -41,8 +41,10 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader @@ -128,6 +130,12 @@ class FakeMatrixClient( private set var removeAvatarCalled: Boolean = false private set + var setUserStatusCalled: Boolean = false + private set + var clearUserStatusCalled: Boolean = false + private set + var setUserStatusResult: Result = Result.success(Unit) + var clearUserStatusResult: Result = Result.success(Unit) private val _userProfile: MutableStateFlow = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl)) override val userProfile: StateFlow = _userProfile @@ -250,6 +258,28 @@ class FakeMatrixClient( return removeAvatarResult } + override suspend fun setUserStatus(status: UserStatus): Result { + setUserStatusCalled = true + if (setUserStatusResult.isSuccess) { + _userProfile.emit(_userProfile.value.copy( + rawStatus = status, + displayedStatus = DisplayedStatus.UserSet(status), + )) + } + return setUserStatusResult + } + + override suspend fun clearUserStatus(): Result { + clearUserStatusCalled = true + if (clearUserStatusResult.isSuccess) { + _userProfile.emit(_userProfile.value.copy( + rawStatus = null, + displayedStatus = null, + )) + } + return clearUserStatusResult + } + override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId) override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result { From f4c79be3285fb74a0db202ba720d41cfb3336951 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:52:51 +0200 Subject: [PATCH 18/64] Stub setUserStatus/clearUserStatus in-memory in RustMatrixClient --- .../libraries/matrix/impl/RustMatrixClient.kt | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 9167b3e2f3..37badadcd4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -48,8 +48,10 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler @@ -258,6 +260,8 @@ class RustMatrixClient( private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) + private var localUserStatus: UserStatus? = null + private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( userId = sessionId, @@ -443,8 +447,12 @@ class RustMatrixClient( override suspend fun getUserProfile(): Result = getProfile(sessionId) .onSuccess { matrixUser -> - _userProfile.emit(matrixUser) - // Also update our session storage + _userProfile.emit( + matrixUser.copy( + rawStatus = localUserStatus, + displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) }, + ) + ) sessionStore.updateUserProfile( sessionId = sessionId.value, displayName = matrixUser.displayName, @@ -474,6 +482,28 @@ class RustMatrixClient( runCatchingExceptions { innerClient.removeAvatar() } } + override suspend fun setUserStatus(status: UserStatus): Result { + localUserStatus = status + _userProfile.emit( + _userProfile.value.copy( + rawStatus = status, + displayedStatus = DisplayedStatus.UserSet(status), + ) + ) + return Result.success(Unit) + } + + override suspend fun clearUserStatus(): Result { + localUserStatus = null + _userProfile.emit( + _userProfile.value.copy( + rawStatus = null, + displayedStatus = null, + ) + ) + return Result.success(Unit) + } + override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) { runCatchingExceptions { innerClient.joinRoomById(roomId.value).destroy() From 42966b393f4c20632236c5c0035f78bee91c90b0 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:52:51 +0200 Subject: [PATCH 19/64] Add user status string resources and PredefinedUserStatus enum --- .../impl/userstatus/PredefinedUserStatus.kt | 19 +++++++++++++++++++ .../impl/src/main/res/values/temporary.xml | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt create mode 100644 features/preferences/impl/src/main/res/values/temporary.xml diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt new file mode 100644 index 0000000000..78fec19f7a --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt @@ -0,0 +1,19 @@ +/* + * 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.features.preferences.impl.userstatus + +import androidx.annotation.StringRes +import io.element.android.features.preferences.impl.R + +enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) { + IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting), + FOCUS_TIME("💡", R.string.common_user_status_focus_time), + ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road), + BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back), + AWAY("🌴", R.string.common_user_status_away), +} diff --git a/features/preferences/impl/src/main/res/values/temporary.xml b/features/preferences/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..b22f265891 --- /dev/null +++ b/features/preferences/impl/src/main/res/values/temporary.xml @@ -0,0 +1,18 @@ + + + + In a meeting + Focus time + On the road + Be right back + Away + On a call + Custom… + What\'s your status? + Custom status… + From 1640adcf9d8e9b2006e552be5ccd856d0614c317 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:56:41 +0200 Subject: [PATCH 20/64] Fix thread-safety and dispatcher consistency in user status stubs --- .../android/libraries/matrix/impl/RustMatrixClient.kt | 10 +++++----- .../android/libraries/matrix/test/FakeMatrixClient.kt | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 37badadcd4..92107f3ac2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -260,7 +260,7 @@ class RustMatrixClient( private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) - private var localUserStatus: UserStatus? = null + @Volatile private var localUserStatus: UserStatus? = null private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( @@ -482,7 +482,7 @@ class RustMatrixClient( runCatchingExceptions { innerClient.removeAvatar() } } - override suspend fun setUserStatus(status: UserStatus): Result { + override suspend fun setUserStatus(status: UserStatus): Result = withContext(sessionDispatcher) { localUserStatus = status _userProfile.emit( _userProfile.value.copy( @@ -490,10 +490,10 @@ class RustMatrixClient( displayedStatus = DisplayedStatus.UserSet(status), ) ) - return Result.success(Unit) + Result.success(Unit) } - override suspend fun clearUserStatus(): Result { + override suspend fun clearUserStatus(): Result = withContext(sessionDispatcher) { localUserStatus = null _userProfile.emit( _userProfile.value.copy( @@ -501,7 +501,7 @@ class RustMatrixClient( displayedStatus = null, ) ) - return Result.success(Unit) + Result.success(Unit) } override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index dce089537f..a13b4b6ad6 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -258,7 +258,7 @@ class FakeMatrixClient( return removeAvatarResult } - override suspend fun setUserStatus(status: UserStatus): Result { + override suspend fun setUserStatus(status: UserStatus): Result = simulateLongTask { setUserStatusCalled = true if (setUserStatusResult.isSuccess) { _userProfile.emit(_userProfile.value.copy( @@ -269,7 +269,7 @@ class FakeMatrixClient( return setUserStatusResult } - override suspend fun clearUserStatus(): Result { + override suspend fun clearUserStatus(): Result = simulateLongTask { clearUserStatusCalled = true if (clearUserStatusResult.isSuccess) { _userProfile.emit(_userProfile.value.copy( From eca45b574eab879fffc8062d046436fcbfff7352 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 14:57:39 +0200 Subject: [PATCH 21/64] Add UserStatusState and UserStatusEvent --- .../impl/userstatus/UserStatusEvent.kt | 25 +++++++++++++++++++ .../impl/userstatus/UserStatusState.kt | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt new file mode 100644 index 0000000000..703f8dadae --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt @@ -0,0 +1,25 @@ +/* + * 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.features.preferences.impl.userstatus + +import io.element.android.libraries.matrix.api.user.UserStatus + +sealed interface UserStatusEvent { + /** User tapped the status row — open the picker bottom sheet. */ + data object Open : UserStatusEvent + /** User dismissed the bottom sheet without selecting. */ + data object Dismiss : UserStatusEvent + /** User selected a predefined or confirmed a custom status. */ + data class Set(val status: UserStatus) : UserStatusEvent + /** User tapped "Custom…" in the bottom sheet. */ + data object OpenCustomInput : UserStatusEvent + /** User tapped Cancel in the inline custom input row. */ + data object CancelCustomInput : UserStatusEvent + /** User tapped the clear action on an existing status. */ + data object Clear : UserStatusEvent +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt new file mode 100644 index 0000000000..5ae1537758 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -0,0 +1,25 @@ +/* + * 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.features.preferences.impl.userstatus + +import io.element.android.libraries.matrix.api.user.DisplayedStatus + +data class UserStatusState( + val displayedStatus: DisplayedStatus?, + val pickerState: UserStatusPickerState, + val eventSink: (UserStatusEvent) -> Unit, +) + +sealed interface UserStatusPickerState { + data object Hidden : UserStatusPickerState + data object ShowingPicker : UserStatusPickerState + data class CustomInput( + val initialEmoji: String = "😀", + val initialText: String = "", + ) : UserStatusPickerState +} From 0528b787c90d0a4fa23635aca5476dd3c1d6c616 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 15:37:28 +0200 Subject: [PATCH 22/64] Add UserStatusPresenter with full test coverage --- .../impl/userstatus/UserStatusPresenter.kt | 63 +++++++++ .../userstatus/UserStatusPresenterTest.kt | 132 ++++++++++++++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 4 +- 3 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt new file mode 100644 index 0000000000..a20bcd1b56 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -0,0 +1,63 @@ +/* + * 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.features.preferences.impl.userstatus + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.user.UserStatus +import kotlinx.coroutines.launch + +@Inject +class UserStatusPresenter( + private val matrixClient: MatrixClient, +) : Presenter { + + @Composable + override fun present(): UserStatusState { + val userProfile by matrixClient.userProfile.collectAsState() + var pickerState by remember { mutableStateOf(UserStatusPickerState.Hidden) } + val coroutineScope = rememberCoroutineScope() + + fun handleEvent(event: UserStatusEvent) { + when (event) { + UserStatusEvent.Open -> pickerState = UserStatusPickerState.ShowingPicker + UserStatusEvent.Dismiss -> pickerState = UserStatusPickerState.Hidden + UserStatusEvent.OpenCustomInput -> { + val raw = userProfile.rawStatus + pickerState = UserStatusPickerState.CustomInput( + initialEmoji = raw?.emoji ?: "😀", + initialText = raw?.text ?: "", + ) + } + is UserStatusEvent.Set -> { + pickerState = UserStatusPickerState.Hidden + coroutineScope.launch { matrixClient.setUserStatus(event.status) } + } + UserStatusEvent.Clear -> { + pickerState = UserStatusPickerState.Hidden + coroutineScope.launch { matrixClient.clearUserStatus() } + } + UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden + } + } + + return UserStatusState( + displayedStatus = userProfile.displayedStatus, + pickerState = pickerState, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt new file mode 100644 index 0000000000..d8187462f8 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -0,0 +1,132 @@ +/* + * 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.features.preferences.impl.userstatus + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus +import io.element.android.libraries.matrix.test.FakeMatrixClient +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UserStatusPresenterTest { + + private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) = + UserStatusPresenter(matrixClient = client) + + @Test + fun `initial state - no status set`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + val state = awaitItem() + assertThat(state.displayedStatus).isNull() + assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden) + } + } + + @Test + fun `Open event transitions to ShowingPicker`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + val state = awaitItem() + state.eventSink(UserStatusEvent.Open) + assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker) + } + } + + @Test + fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + val state = awaitItem() + state.eventSink(UserStatusEvent.Open) + awaitItem() // ShowingPicker + state.eventSink(UserStatusEvent.Dismiss) + assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden) + } + } + + @Test + fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest { + val client = FakeMatrixClient() + moleculeFlow(RecompositionMode.Immediate) { + createPresenter(client).present() + }.test { + val status = UserStatus(emoji = "💬", text = "In a meeting") + awaitItem().eventSink(UserStatusEvent.Set(status)) + val newState = awaitItem() + assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) + assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status)) + assertThat(client.setUserStatusCalled).isTrue() + } + } + + @Test + fun `OpenCustomInput with no existing status uses defaults`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + val state = awaitItem() + val pickerState = state.pickerState as UserStatusPickerState.CustomInput + assertThat(pickerState.initialEmoji).isEqualTo("😀") + assertThat(pickerState.initialText).isEqualTo("") + } + } + + @Test + fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest { + val client = FakeMatrixClient() + moleculeFlow(RecompositionMode.Immediate) { + createPresenter(client).present() + }.test { + val status = UserStatus(emoji = "🌴", text = "Away") + awaitItem().eventSink(UserStatusEvent.Set(status)) + // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + val state = awaitItem() + val pickerState = state.pickerState as UserStatusPickerState.CustomInput + assertThat(pickerState.initialEmoji).isEqualTo("🌴") + assertThat(pickerState.initialText).isEqualTo("Away") + } + } + + @Test + fun `CancelCustomInput goes back to Hidden`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + awaitItem().eventSink(UserStatusEvent.CancelCustomInput) // CustomInput state, fire Cancel + assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden) + } + } + + @Test + fun `Clear event calls clearUserStatus and clears displayed status`() = runTest { + val client = FakeMatrixClient() + moleculeFlow(RecompositionMode.Immediate) { + createPresenter(client).present() + }.test { + val status = UserStatus(emoji = "☕", text = "Be right back") + awaitItem().eventSink(UserStatusEvent.Set(status)) + // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition + awaitItem().eventSink(UserStatusEvent.Clear) + val clearedState = awaitItem() + assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) + assertThat(clearedState.displayedStatus).isNull() + assertThat(client.clearUserStatusCalled).isTrue() + } + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index a13b4b6ad6..dce089537f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -258,7 +258,7 @@ class FakeMatrixClient( return removeAvatarResult } - override suspend fun setUserStatus(status: UserStatus): Result = simulateLongTask { + override suspend fun setUserStatus(status: UserStatus): Result { setUserStatusCalled = true if (setUserStatusResult.isSuccess) { _userProfile.emit(_userProfile.value.copy( @@ -269,7 +269,7 @@ class FakeMatrixClient( return setUserStatusResult } - override suspend fun clearUserStatus(): Result = simulateLongTask { + override suspend fun clearUserStatus(): Result { clearUserStatusCalled = true if (clearUserStatusResult.isSuccess) { _userProfile.emit(_userProfile.value.copy( From af5a237b4ac11d1f0312c05c5e738292fe55a29e Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 15:37:28 +0200 Subject: [PATCH 23/64] Add UserStatusView composables with previews --- .../impl/userstatus/UserStatusView.kt | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt new file mode 100644 index 0000000000..78f96042e1 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -0,0 +1,246 @@ +/* + * 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.features.preferences.impl.userstatus + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun UserStatusRow( + state: UserStatusState, + modifier: Modifier = Modifier, +) { + when (val pickerState = state.pickerState) { + UserStatusPickerState.Hidden, UserStatusPickerState.ShowingPicker -> { + val displayedStatus = state.displayedStatus + if (displayedStatus != null) { + CurrentStatusRow( + displayedStatus = displayedStatus, + onClick = { state.eventSink(UserStatusEvent.Open) }, + onClear = { state.eventSink(UserStatusEvent.Clear) }, + modifier = modifier, + ) + } else { + EmptyStatusRow( + onClick = { state.eventSink(UserStatusEvent.Open) }, + modifier = modifier, + ) + } + if (pickerState == UserStatusPickerState.ShowingPicker) { + UserStatusPickerBottomSheet( + onDismiss = { state.eventSink(UserStatusEvent.Dismiss) }, + onSelectPredefined = { status -> state.eventSink(UserStatusEvent.Set(status)) }, + onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, + ) + } + } + is UserStatusPickerState.CustomInput -> { + CustomStatusInputRow( + initialEmoji = pickerState.initialEmoji, + initialText = pickerState.initialText, + onConfirm = { emoji, text -> + state.eventSink(UserStatusEvent.Set(UserStatus(emoji, text))) + }, + onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, + modifier = modifier, + ) + } + } +} + +@Composable +private fun EmptyStatusRow( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.screen_preferences_user_status_placeholder), + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = { + Text(text = "🙂", style = ElementTheme.typography.fontBodyLgRegular) + }, + modifier = modifier.clickable(onClick = onClick), + ) +} + +@Composable +private fun CurrentStatusRow( + displayedStatus: DisplayedStatus, + onClick: () -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + val (emoji, text) = when (displayedStatus) { + is DisplayedStatus.UserSet -> displayedStatus.status.emoji to displayedStatus.status.text + is DisplayedStatus.InCall -> "🎧" to stringResource(R.string.common_user_status_on_a_call) + } + ListItem( + headlineContent = { Text(text = text) }, + leadingContent = { + Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) + }, + trailingContent = { + TextButton(onClick = onClear) { + Text(text = stringResource(CommonStrings.action_clear)) + } + }, + modifier = modifier.clickable(onClick = onClick), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UserStatusPickerBottomSheet( + onDismiss: () -> Unit, + onSelectPredefined: (UserStatus) -> Unit, + onSelectCustom: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + PredefinedUserStatus.entries.forEach { predefined -> + val label = stringResource(predefined.labelRes) + ListItem( + headlineContent = { Text(text = label) }, + leadingContent = { + Text(text = predefined.emoji, style = ElementTheme.typography.fontBodyLgRegular) + }, + modifier = Modifier.clickable { + onSelectPredefined(UserStatus(emoji = predefined.emoji, text = label)) + }, + ) + } + ListItem( + headlineContent = { + Text(text = stringResource(R.string.common_user_status_custom)) + }, + leadingContent = { + Text(text = "✏️", style = ElementTheme.typography.fontBodyLgRegular) + }, + modifier = Modifier.clickable(onClick = onSelectCustom), + ) + } +} + +@Composable +private fun CustomStatusInputRow( + initialEmoji: String, + initialText: String, + onConfirm: (emoji: String, text: String) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + var emoji by remember { mutableStateOf(initialEmoji) } + var text by remember { mutableStateOf(initialText) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Surface( + shape = CircleShape, + color = ElementTheme.colors.bgSubtleSecondary, + modifier = Modifier + .size(40.dp) + .clickable { }, + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) + } + } + OutlinedTextField( + value = text, + onValueChange = { if (it.length <= 30) text = it }, + placeholder = { + Text(text = stringResource(R.string.screen_preferences_user_status_custom_hint)) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { if (text.isNotBlank()) onConfirm(emoji, text) } + ), + singleLine = true, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + TextButton(onClick = onCancel) { + Text(text = stringResource(CommonStrings.action_cancel)) + } + } +} + +@PreviewsDayNight +@Composable +internal fun UserStatusRowEmptyPreview() = ElementPreview { + UserStatusRow( + state = UserStatusState( + displayedStatus = null, + pickerState = UserStatusPickerState.Hidden, + eventSink = {}, + ) + ) +} + +@PreviewsDayNight +@Composable +internal fun UserStatusRowSetPreview() = ElementPreview { + UserStatusRow( + state = UserStatusState( + displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")), + pickerState = UserStatusPickerState.Hidden, + eventSink = {}, + ) + ) +} + +@PreviewsDayNight +@Composable +internal fun UserStatusRowCustomInputPreview() = ElementPreview { + UserStatusRow( + state = UserStatusState( + displayedStatus = null, + pickerState = UserStatusPickerState.CustomInput(initialEmoji = "😀", initialText = ""), + eventSink = {}, + ) + ) +} From 6c34d647ca9056b5126f3d799c6905f3a2d43f09 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 15:48:54 +0200 Subject: [PATCH 24/64] Wire UserStatusPresenter and UserStatusRow into PreferencesRoot --- .../impl/root/PreferencesRootPresenter.kt | 4 ++++ .../impl/root/PreferencesRootState.kt | 2 ++ .../impl/root/PreferencesRootStateProvider.kt | 4 ++++ .../impl/root/PreferencesRootView.kt | 11 +++++++--- .../userstatus/UserStatusStateProvider.kt | 20 +++++++++++++++++++ .../impl/root/PreferencesRootPresenterTest.kt | 2 ++ .../impl/root/PreferencesRootViewTest.kt | 11 +++++++--- 7 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index f407149007..fa69382cf8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter @@ -57,9 +58,11 @@ class PreferencesRootPresenter( private val featureFlagService: FeatureFlagService, private val sessionStore: SessionStore, private val sessionEnterpriseService: SessionEnterpriseService, + private val userStatusPresenter: Presenter, ) : Presenter { @Composable override fun present(): PreferencesRootState { + val userStatusState = userStatusPresenter.present() val coroutineScope = rememberCoroutineScope() val matrixUser = matrixClient.userProfile.collectAsState() LaunchedEffect(Unit) { @@ -137,6 +140,7 @@ class PreferencesRootPresenter( return PreferencesRootState( myUser = matrixUser.value, + userStatusState = userStatusState, version = remember { versionFormatter.get() }, deviceId = matrixClient.deviceId, isMultiAccountEnabled = isMultiAccountEnabled, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index 6474f07a69..8163e4b7a0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.root import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser @@ -16,6 +17,7 @@ import kotlinx.collections.immutable.ImmutableList data class PreferencesRootState( val myUser: MatrixUser, + val userStatusState: UserStatusState, val version: String, val deviceId: DeviceId?, val isMultiAccountEnabled: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index d53cd008e4..9e1557fd7b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -11,6 +11,8 @@ package io.element.android.features.preferences.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.preferences.impl.userstatus.UserStatusState +import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser @@ -86,6 +88,7 @@ fun aPreferencesRootState( nbOfBlockedUsers: Int = 0, showLabsItem: Boolean = false, directLogoutState: DirectLogoutState = aDirectLogoutState(), + userStatusState: UserStatusState = aUserStatusState(), snackbarMessage: SnackbarMessage? = null, eventSink: (PreferencesRootEvent) -> Unit = {}, ) = PreferencesRootState( @@ -105,6 +108,7 @@ fun aPreferencesRootState( nbOfBlockedUsers = nbOfBlockedUsers, showLabsItem = showLabsItem, directLogoutState = directLogoutState, + userStatusState = userStatusState, snackbarMessage = snackbarMessage, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 4afc2d27bf..d92c1e4613 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.root import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -23,6 +24,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.user.UserPreferences +import io.element.android.features.preferences.impl.userstatus.UserStatusRow import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.list.ListItemContent @@ -80,15 +82,18 @@ fun PreferencesRootView( }, matrixUser = state.myUser, ) + HorizontalDivider() + UserStatusRow( + state = state.userStatusState, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider() if (state.isMultiAccountEnabled) { MultiAccountSection( state = state, onAddAccountClick = onAddAccountClick, ) - } else { - HorizontalDivider() } - // User status will be added here // 'Account' section ManageAccountSection( state = state, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt new file mode 100644 index 0000000000..65ec264cc4 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt @@ -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.features.preferences.impl.userstatus + +import io.element.android.libraries.matrix.api.user.DisplayedStatus + +fun aUserStatusState( + displayedStatus: DisplayedStatus? = null, + pickerState: UserStatusPickerState = UserStatusPickerState.Hidden, + eventSink: (UserStatusEvent) -> Unit = {}, +) = UserStatusState( + displayedStatus = displayedStatus, + pickerState = pickerState, + eventSink = eventSink, +) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index a6861be12d..0bda80fe0c 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.core.meta.BuildType @@ -346,5 +347,6 @@ class PreferencesRootPresenterTest { featureFlagService = featureFlagService, sessionStore = sessionStore, sessionEnterpriseService = sessionEnterpriseService, + userStatusPresenter = { aUserStatusState() }, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index aded097059..5c9d81875e 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -12,8 +12,11 @@ package io.element.android.features.preferences.impl.root import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.MatrixUser @@ -281,7 +284,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenDeveloperSettings = callback, ) - clickOn(CommonStrings.common_developer_options) + val text = activity!!.getString(CommonStrings.common_developer_options) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -404,7 +408,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onDeactivateClick = callback, ) - clickOn(CommonStrings.action_delete_account) + val text = activity!!.getString(CommonStrings.action_delete_account) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -430,7 +435,7 @@ class PreferencesRootViewTest : RobolectricTest() { eventSink = eventsRecorder, ), ) - onNodeWithText(version).performClick() + onNodeWithText(version).performScrollTo().performClick() eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick) } } From 7309e49286235fa090b33c9bd1f6ff8598e0d1f7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 12 Jun 2026 16:04:42 +0200 Subject: [PATCH 25/64] Wire UserStatusPresenter and UserStatusRow into PreferencesRoot --- .../impl/userstatus/di/UserStatusModule.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt new file mode 100644 index 0000000000..b2be2d75d4 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt @@ -0,0 +1,23 @@ +/* + * 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.features.preferences.impl.userstatus.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.preferences.impl.userstatus.UserStatusPresenter +import io.element.android.features.preferences.impl.userstatus.UserStatusState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@BindingContainer +interface UserStatusModule { + @Binds + fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter +} From eeaaac296995ebbd6ff3a7f5cd3d0c1217e175e8 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 15 Jun 2026 15:22:54 +0200 Subject: [PATCH 26/64] Move custom status emoji/text into CustomInput state (MVI) --- .../impl/userstatus/PredefinedUserStatus.kt | 19 --- .../impl/userstatus/UserStatusEvent.kt | 4 + .../impl/userstatus/UserStatusPresenter.kt | 16 ++- .../impl/userstatus/UserStatusState.kt | 14 +- .../impl/userstatus/UserStatusView.kt | 130 ++++++++++-------- .../impl/userstatus/di/UserStatusModule.kt | 23 ---- .../userstatus/UserStatusPresenterTest.kt | 32 ++++- 7 files changed, 133 insertions(+), 105 deletions(-) delete mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt delete mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt deleted file mode 100644 index 78fec19f7a..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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.features.preferences.impl.userstatus - -import androidx.annotation.StringRes -import io.element.android.features.preferences.impl.R - -enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) { - IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting), - FOCUS_TIME("💡", R.string.common_user_status_focus_time), - ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road), - BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back), - AWAY("🌴", R.string.common_user_status_away), -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt index 703f8dadae..d78be1607b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt @@ -20,6 +20,10 @@ sealed interface UserStatusEvent { data object OpenCustomInput : UserStatusEvent /** User tapped Cancel in the inline custom input row. */ data object CancelCustomInput : UserStatusEvent + /** User changed the emoji in the custom input row. */ + data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent + /** User changed the text in the custom input row. */ + data class UpdateCustomText(val text: String) : UserStatusEvent /** User tapped the clear action on an existing status. */ data object Clear : UserStatusEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index a20bcd1b56..d765f35800 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -14,13 +14,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.user.UserStatus import kotlinx.coroutines.launch -@Inject +@ContributesBinding(SessionScope::class) class UserStatusPresenter( private val matrixClient: MatrixClient, ) : Presenter { @@ -38,8 +40,8 @@ class UserStatusPresenter( UserStatusEvent.OpenCustomInput -> { val raw = userProfile.rawStatus pickerState = UserStatusPickerState.CustomInput( - initialEmoji = raw?.emoji ?: "😀", - initialText = raw?.text ?: "", + emoji = raw?.emoji ?: "😀", + text = raw?.text ?: "", ) } is UserStatusEvent.Set -> { @@ -51,6 +53,14 @@ class UserStatusPresenter( coroutineScope.launch { matrixClient.clearUserStatus() } } UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden + is UserStatusEvent.UpdateCustomEmoji -> { + val current = pickerState as? UserStatusPickerState.CustomInput ?: return + pickerState = current.copy(emoji = event.emoji) + } + is UserStatusEvent.UpdateCustomText -> { + val current = pickerState as? UserStatusPickerState.CustomInput ?: return + pickerState = current.copy(text = event.text) + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt index 5ae1537758..df1a97a5e3 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -7,6 +7,8 @@ package io.element.android.features.preferences.impl.userstatus +import androidx.annotation.StringRes +import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.DisplayedStatus data class UserStatusState( @@ -19,7 +21,15 @@ sealed interface UserStatusPickerState { data object Hidden : UserStatusPickerState data object ShowingPicker : UserStatusPickerState data class CustomInput( - val initialEmoji: String = "😀", - val initialText: String = "", + val emoji: String = "😀", + val text: String = "", ) : UserStatusPickerState } + +enum class PredefinedUserStatus(val emoji: String, @param:StringRes val labelRes: Int) { + IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting), + FOCUS_TIME("💡", R.string.common_user_status_focus_time), + ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road), + BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back), + AWAY("🌴", R.string.common_user_status_away), +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 78f96042e1..956400fa63 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -7,6 +7,7 @@ package io.element.android.features.preferences.impl.userstatus +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -17,26 +18,29 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +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.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @@ -72,11 +76,11 @@ fun UserStatusRow( } is UserStatusPickerState.CustomInput -> { CustomStatusInputRow( - initialEmoji = pickerState.initialEmoji, - initialText = pickerState.initialText, - onConfirm = { emoji, text -> - state.eventSink(UserStatusEvent.Set(UserStatus(emoji, text))) - }, + emoji = pickerState.emoji, + text = pickerState.text, + onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, + onTextChange = { state.eventSink(UserStatusEvent.UpdateCustomText(it)) }, + onConfirm = { state.eventSink(UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.text))) }, onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, modifier = modifier, ) @@ -91,14 +95,9 @@ private fun EmptyStatusRow( ) { ListItem( headlineContent = { - Text( - text = stringResource(R.string.screen_preferences_user_status_placeholder), - color = ElementTheme.colors.textSecondary, - ) - }, - leadingContent = { - Text(text = "🙂", style = ElementTheme.typography.fontBodyLgRegular) + Text(text = stringResource(R.string.screen_preferences_user_status_placeholder)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), modifier = modifier.clickable(onClick = onClick), ) } @@ -116,15 +115,14 @@ private fun CurrentStatusRow( } ListItem( headlineContent = { Text(text = text) }, - leadingContent = { - Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) - }, - trailingContent = { - TextButton(onClick = onClear) { - Text(text = stringResource(CommonStrings.action_clear)) + leadingContent = ListItemContent.Text(emoji), + trailingContent = ListItemContent.Custom({ + IconButton(onClick = onClear) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) } - }, - modifier = modifier.clickable(onClick = onClick), + }), + onClick = onClick, + modifier = modifier, ) } @@ -134,16 +132,15 @@ private fun UserStatusPickerBottomSheet( onDismiss: () -> Unit, onSelectPredefined: (UserStatus) -> Unit, onSelectCustom: () -> Unit, + modifier: Modifier = Modifier, ) { - ModalBottomSheet(onDismissRequest = onDismiss) { + ModalBottomSheet(onDismissRequest = onDismiss, scrollable = true, modifier = modifier) { PredefinedUserStatus.entries.forEach { predefined -> val label = stringResource(predefined.labelRes) ListItem( headlineContent = { Text(text = label) }, - leadingContent = { - Text(text = predefined.emoji, style = ElementTheme.typography.fontBodyLgRegular) - }, - modifier = Modifier.clickable { + leadingContent = ListItemContent.Text(text = predefined.emoji), + onClick = { onSelectPredefined(UserStatus(emoji = predefined.emoji, text = label)) }, ) @@ -152,25 +149,54 @@ private fun UserStatusPickerBottomSheet( headlineContent = { Text(text = stringResource(R.string.common_user_status_custom)) }, - leadingContent = { - Text(text = "✏️", style = ElementTheme.typography.fontBodyLgRegular) - }, - modifier = Modifier.clickable(onClick = onSelectCustom), + leadingContent = ListItemContent.Text(text = "✏️"), + onClick = onSelectCustom, ) } } @Composable private fun CustomStatusInputRow( - initialEmoji: String, - initialText: String, - onConfirm: (emoji: String, text: String) -> Unit, + emoji: String, + text: String, + onEmojiChange: (String) -> Unit, + onTextChange: (String) -> Unit, + onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, ) { - var emoji by remember { mutableStateOf(initialEmoji) } - var text by remember { mutableStateOf(initialText) } + ListItem( + headlineContent = { + TextField( + value = text, + onValueChange = { if (it.length <= 30) onTextChange(it) }, + placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { if (text.isNotBlank()) onConfirm() } + ), + singleLine = true, + ) + }, + trailingContent = ListItemContent.Custom({ + TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) + }), + leadingContent = ListItemContent.Custom( { + Surface( + shape = CircleShape, + border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary), + modifier = Modifier + .size(50.dp) + .clickable { }, + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) + } + } + }) + ) + /* Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -188,25 +214,21 @@ private fun CustomStatusInputRow( Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) } } - OutlinedTextField( + TextField( value = text, onValueChange = { if (it.length <= 30) text = it }, - placeholder = { - Text(text = stringResource(R.string.screen_preferences_user_status_custom_hint)) - }, + placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { if (text.isNotBlank()) onConfirm(emoji, text) } ), singleLine = true, - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp), + modifier = Modifier.weight(1f).padding(horizontal = 8.dp), ) - TextButton(onClick = onCancel) { - Text(text = stringResource(CommonStrings.action_cancel)) - } + TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) } + + */ } @PreviewsDayNight @@ -239,7 +261,7 @@ internal fun UserStatusRowCustomInputPreview() = ElementPreview { UserStatusRow( state = UserStatusState( displayedStatus = null, - pickerState = UserStatusPickerState.CustomInput(initialEmoji = "😀", initialText = ""), + pickerState = UserStatusPickerState.CustomInput(emoji = "😀", text = ""), eventSink = {}, ) ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt deleted file mode 100644 index b2be2d75d4..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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.features.preferences.impl.userstatus.di - -import dev.zacsweers.metro.BindingContainer -import dev.zacsweers.metro.Binds -import dev.zacsweers.metro.ContributesTo -import io.element.android.features.preferences.impl.userstatus.UserStatusPresenter -import io.element.android.features.preferences.impl.userstatus.UserStatusState -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.di.SessionScope - -@ContributesTo(SessionScope::class) -@BindingContainer -interface UserStatusModule { - @Binds - fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter -} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt index d8187462f8..42f1e53323 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -80,8 +80,8 @@ class UserStatusPresenterTest { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput - assertThat(pickerState.initialEmoji).isEqualTo("😀") - assertThat(pickerState.initialText).isEqualTo("") + assertThat(pickerState.emoji).isEqualTo("😀") + assertThat(pickerState.text).isEqualTo("") } } @@ -97,8 +97,32 @@ class UserStatusPresenterTest { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput - assertThat(pickerState.initialEmoji).isEqualTo("🌴") - assertThat(pickerState.initialText).isEqualTo("Away") + assertThat(pickerState.emoji).isEqualTo("🌴") + assertThat(pickerState.text).isEqualTo("Away") + } + } + + @Test + fun `UpdateCustomText updates text in CustomInput state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + awaitItem().eventSink(UserStatusEvent.UpdateCustomText("Focusing")) + val state = awaitItem() + assertThat((state.pickerState as UserStatusPickerState.CustomInput).text).isEqualTo("Focusing") + } + } + + @Test + fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("🚀")) + val state = awaitItem() + assertThat((state.pickerState as UserStatusPickerState.CustomInput).emoji).isEqualTo("🚀") } } From 73a8a3d5d88ceb915f57b0a986a8fd7b67afecfc Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 15 Jun 2026 15:29:50 +0200 Subject: [PATCH 27/64] Use TextFieldState in CustomInput state for proper MVI text handling --- .../impl/userstatus/UserStatusEvent.kt | 2 -- .../impl/userstatus/UserStatusPresenter.kt | 15 +++++--- .../impl/userstatus/UserStatusState.kt | 3 +- .../impl/userstatus/UserStatusView.kt | 34 ++++++++++--------- .../userstatus/UserStatusPresenterTest.kt | 17 ++-------- 5 files changed, 33 insertions(+), 38 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt index d78be1607b..80dd02e08a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt @@ -22,8 +22,6 @@ sealed interface UserStatusEvent { data object CancelCustomInput : UserStatusEvent /** User changed the emoji in the custom input row. */ data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent - /** User changed the text in the custom input row. */ - data class UpdateCustomText(val text: String) : UserStatusEvent /** User tapped the clear action on an existing status. */ data object Clear : UserStatusEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index d765f35800..0bb626b159 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -7,6 +7,9 @@ package io.element.android.features.preferences.impl.userstatus +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -31,6 +34,7 @@ class UserStatusPresenter( override fun present(): UserStatusState { val userProfile by matrixClient.userProfile.collectAsState() var pickerState by remember { mutableStateOf(UserStatusPickerState.Hidden) } + val customTextFieldState = remember { TextFieldState() } val coroutineScope = rememberCoroutineScope() fun handleEvent(event: UserStatusEvent) { @@ -39,9 +43,14 @@ class UserStatusPresenter( UserStatusEvent.Dismiss -> pickerState = UserStatusPickerState.Hidden UserStatusEvent.OpenCustomInput -> { val raw = userProfile.rawStatus + if (raw != null) { + customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text) + } else { + customTextFieldState.clearText() + } pickerState = UserStatusPickerState.CustomInput( emoji = raw?.emoji ?: "😀", - text = raw?.text ?: "", + textFieldState = customTextFieldState, ) } is UserStatusEvent.Set -> { @@ -57,10 +66,6 @@ class UserStatusPresenter( val current = pickerState as? UserStatusPickerState.CustomInput ?: return pickerState = current.copy(emoji = event.emoji) } - is UserStatusEvent.UpdateCustomText -> { - val current = pickerState as? UserStatusPickerState.CustomInput ?: return - pickerState = current.copy(text = event.text) - } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt index df1a97a5e3..b1fe11d1ed 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -8,6 +8,7 @@ package io.element.android.features.preferences.impl.userstatus import androidx.annotation.StringRes +import androidx.compose.foundation.text.input.TextFieldState import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.DisplayedStatus @@ -22,7 +23,7 @@ sealed interface UserStatusPickerState { data object ShowingPicker : UserStatusPickerState data class CustomInput( val emoji: String = "😀", - val text: String = "", + val textFieldState: TextFieldState, ) : UserStatusPickerState } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 956400fa63..b63ae2630e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -15,8 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -32,6 +34,7 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.FilledTextField 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.IconSource @@ -40,7 +43,6 @@ import io.element.android.libraries.designsystem.theme.components.ModalBottomShe import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton -import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @@ -77,10 +79,13 @@ fun UserStatusRow( is UserStatusPickerState.CustomInput -> { CustomStatusInputRow( emoji = pickerState.emoji, - text = pickerState.text, + textFieldState = pickerState.textFieldState, onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, - onTextChange = { state.eventSink(UserStatusEvent.UpdateCustomText(it)) }, - onConfirm = { state.eventSink(UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.text))) }, + onConfirm = { + state.eventSink( + UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) + ) + }, onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, modifier = modifier, ) @@ -158,24 +163,21 @@ private fun UserStatusPickerBottomSheet( @Composable private fun CustomStatusInputRow( emoji: String, - text: String, + textFieldState: TextFieldState, onEmojiChange: (String) -> Unit, - onTextChange: (String) -> Unit, onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, ) { ListItem( headlineContent = { - TextField( - value = text, - onValueChange = { if (it.length <= 30) onTextChange(it) }, - placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), + FilledTextField( + state = textFieldState, + placeholder = { Text(stringResource(R.string.screen_preferences_user_status_custom_hint)) }, + inputTransformation = InputTransformation { if (length > 30) revertAllChanges() }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { if (text.isNotBlank()) onConfirm() } - ), - singleLine = true, + keyboardActions = { if (textFieldState.text.isNotBlank()) onConfirm() }, + lineLimits = TextFieldLineLimits.SingleLine, ) }, trailingContent = ListItemContent.Custom({ @@ -261,7 +263,7 @@ internal fun UserStatusRowCustomInputPreview() = ElementPreview { UserStatusRow( state = UserStatusState( displayedStatus = null, - pickerState = UserStatusPickerState.CustomInput(emoji = "😀", text = ""), + pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState()), eventSink = {}, ) ) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt index 42f1e53323..349361a61b 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.test.FakeMatrixClient import kotlinx.coroutines.test.runTest import org.junit.Test +import androidx.compose.foundation.text.input.TextFieldState class UserStatusPresenterTest { @@ -81,7 +82,7 @@ class UserStatusPresenterTest { val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput assertThat(pickerState.emoji).isEqualTo("😀") - assertThat(pickerState.text).isEqualTo("") + assertThat(pickerState.textFieldState.text.toString()).isEqualTo("") } } @@ -98,19 +99,7 @@ class UserStatusPresenterTest { val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput assertThat(pickerState.emoji).isEqualTo("🌴") - assertThat(pickerState.text).isEqualTo("Away") - } - } - - @Test - fun `UpdateCustomText updates text in CustomInput state`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { - awaitItem().eventSink(UserStatusEvent.OpenCustomInput) - awaitItem().eventSink(UserStatusEvent.UpdateCustomText("Focusing")) - val state = awaitItem() - assertThat((state.pickerState as UserStatusPickerState.CustomInput).text).isEqualTo("Focusing") + assertThat(pickerState.textFieldState.text.toString()).isEqualTo("Away") } } From 57617a362a38fd2d9d023f309f56afeeaabe3668 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 15 Jun 2026 16:05:20 +0200 Subject: [PATCH 28/64] Add TextFieldState overload to design system TextField, use it in UserStatusView --- .../impl/userstatus/UserStatusView.kt | 11 ++-- .../theme/components/TextField.kt | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index b63ae2630e..efb06a42de 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -34,7 +34,7 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.FilledTextField +import io.element.android.libraries.designsystem.theme.components.TextField 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.IconSource @@ -171,12 +171,12 @@ private fun CustomStatusInputRow( ) { ListItem( headlineContent = { - FilledTextField( + TextField( state = textFieldState, - placeholder = { Text(stringResource(R.string.screen_preferences_user_status_custom_hint)) }, + placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), inputTransformation = InputTransformation { if (length > 30) revertAllChanges() }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = { if (textFieldState.text.isNotBlank()) onConfirm() }, + onKeyboardAction = { if (textFieldState.text.isNotBlank()) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, ) }, @@ -195,7 +195,8 @@ private fun CustomStatusInputRow( Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) } } - }) + }), + modifier = modifier, ) /* diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt index 5e0a81ba03..d4e08eacf1 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -24,6 +24,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.KeyboardActionHandler +import androidx.compose.foundation.text.input.TextFieldDecorator +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -161,6 +166,55 @@ fun TextField( } } +@Composable +fun TextField( + state: TextFieldState, + modifier: Modifier = Modifier, + label: String? = null, + supportingText: String? = null, + placeholder: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + validity: TextFieldValidity = TextFieldValidity.None, + enabled: Boolean = true, + readOnly: Boolean = false, + inputTransformation: InputTransformation? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + onKeyboardAction: KeyboardActionHandler? = null, + lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val isFocused by interactionSource.collectIsFocusedAsState() + BasicTextField( + state = state, + modifier = modifier, + textStyle = textFieldStyle(enabled), + interactionSource = interactionSource, + enabled = enabled, + readOnly = readOnly, + inputTransformation = inputTransformation, + cursorBrush = SolidColor(ElementTheme.colors.textPrimary), + keyboardOptions = keyboardOptions, + onKeyboardAction = onKeyboardAction, + lineLimits = lineLimits, + decorator = TextFieldDecorator { innerTextField -> + DecorationBox( + label = label, + readOnly = readOnly, + enabled = enabled, + isFocused = isFocused, + validity = validity, + leadingIcon = leadingIcon, + placeholder = placeholder, + isTextEmpty = state.text.isEmpty(), + innerTextField = innerTextField, + trailingIcon = trailingIcon, + supportingText = supportingText, + ) + } + ) +} + @Composable private fun DecorationBox( label: String?, From 167c1ced80e30b673597a8d7205f9f1d1af73066 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 15 Jun 2026 17:57:52 +0200 Subject: [PATCH 29/64] Add clear button in custom status text field, toggle Cancel/Save on text presence --- .../impl/userstatus/UserStatusView.kt | 60 +++++++------------ 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index efb06a42de..2f3495ec1e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -19,9 +19,14 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.maxLength import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -102,6 +107,9 @@ private fun EmptyStatusRow( headlineContent = { Text(text = stringResource(R.string.screen_preferences_user_status_placeholder)) }, + trailingContent = ListItemContent.Custom({ + Box(modifier = Modifier.minimumInteractiveComponentSize()) + }), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), modifier = modifier.clickable(onClick = onClick), ) @@ -169,19 +177,31 @@ private fun CustomStatusInputRow( onCancel: () -> Unit, modifier: Modifier = Modifier, ) { + val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } } ListItem( headlineContent = { TextField( state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), - inputTransformation = InputTransformation { if (length > 30) revertAllChanges() }, + inputTransformation = InputTransformation.maxLength(maxLength = 30), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - onKeyboardAction = { if (textFieldState.text.isNotBlank()) onConfirm() }, + onKeyboardAction = { if (hasText) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, + trailingIcon = if (hasText) { + { + IconButton(onClick = { textFieldState.clearText() }) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + } + } + } else null, ) }, trailingContent = ListItemContent.Custom({ - TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) + if (hasText) { + TextButton(onClick = onConfirm, text = stringResource(CommonStrings.action_save)) + } else { + TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) + } }), leadingContent = ListItemContent.Custom( { Surface( @@ -198,40 +218,6 @@ private fun CustomStatusInputRow( }), modifier = modifier, ) - - /* - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - ) { - Surface( - shape = CircleShape, - color = ElementTheme.colors.bgSubtleSecondary, - modifier = Modifier - .size(40.dp) - .clickable { }, - ) { - Box(contentAlignment = Alignment.Center) { - Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) - } - } - TextField( - value = text, - onValueChange = { if (it.length <= 30) text = it }, - placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { if (text.isNotBlank()) onConfirm(emoji, text) } - ), - singleLine = true, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - ) - TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) - } - - */ } @PreviewsDayNight From 889fd08b5c20cc2d5b9dd6b61332e95b94d76282 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 16 Jun 2026 11:25:08 +0200 Subject: [PATCH 30/64] Fix button width jitter using rememberTextMeasurer to derive stable min-width --- .../impl/userstatus/UserStatusView.kt | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 2f3495ec1e..0c11faaaec 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -10,10 +10,9 @@ package io.element.android.features.preferences.impl.userstatus import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation @@ -29,8 +28,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons @@ -38,8 +39,6 @@ import io.element.android.features.preferences.impl.R import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.TextField 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.IconSource @@ -48,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.ModalBottomShe import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @@ -128,7 +128,9 @@ private fun CurrentStatusRow( } ListItem( headlineContent = { Text(text = text) }, - leadingContent = ListItemContent.Text(emoji), + leadingContent = ListItemContent.Custom({ + Text(text = emoji, modifier = Modifier.size(24.dp)) + }), trailingContent = ListItemContent.Custom({ IconButton(onClick = onClear) { Icon(imageVector = CompoundIcons.Close(), contentDescription = null) @@ -178,32 +180,47 @@ private fun CustomStatusInputRow( modifier: Modifier = Modifier, ) { val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } } + val saveLabel = stringResource(CommonStrings.action_save) + val cancelLabel = stringResource(CommonStrings.action_cancel) + val textStyle = ElementTheme.typography.fontBodyLgMedium + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + // Measure both labels and derive a stable min-width so the button never resizes. + // 24.dp = 12.dp start + 12.dp end padding of TextButton (Large, no icon). + val minButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) { + val saveWidth = textMeasurer.measure(saveLabel, textStyle).size.width + val cancelWidth = textMeasurer.measure(cancelLabel, textStyle).size.width + with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 24.dp + } ListItem( headlineContent = { TextField( + modifier = Modifier.fillMaxWidth(), state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), inputTransformation = InputTransformation.maxLength(maxLength = 30), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), onKeyboardAction = { if (hasText) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, - trailingIcon = if (hasText) { - { - IconButton(onClick = { textFieldState.clearText() }) { - Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + trailingIcon = { + if (hasText) { + Box(modifier = Modifier.clickable { + textFieldState.clearText() + }) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_cancel)) } } - } else null, + } ) }, trailingContent = ListItemContent.Custom({ - if (hasText) { - TextButton(onClick = onConfirm, text = stringResource(CommonStrings.action_save)) - } else { - TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) - } + TextButton( + onClick = if (hasText) onConfirm else onCancel, + text = if (hasText) saveLabel else cancelLabel, + modifier = Modifier.widthIn(min = minButtonWidth), + ) }), - leadingContent = ListItemContent.Custom( { + leadingContent = ListItemContent.Custom({ Surface( shape = CircleShape, border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary), From 078b63fd7cc6b8f85d9c8f00af90912bec73b1c5 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 16 Jun 2026 11:32:47 +0200 Subject: [PATCH 31/64] Move preview cases to UserStatusStateProvider, use @PreviewParameter in view --- .../userstatus/UserStatusStateProvider.kt | 14 ++++++++ .../impl/userstatus/UserStatusView.kt | 35 ++----------------- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt index 65ec264cc4..7a7a1d4dac 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt @@ -7,7 +7,21 @@ package io.element.android.features.preferences.impl.userstatus +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus + +internal class UserStatusStateProvider : PreviewParameterProvider { + override val values get() = sequenceOf( + aUserStatusState(), + aUserStatusState(displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away"))), + aUserStatusState(displayedStatus = DisplayedStatus.InCall(callJoinedTs = 0L)), + aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker), + aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState())), + aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))), + ) +} fun aUserStatusState( displayedStatus: DisplayedStatus? = null, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 0c11faaaec..3c30701834 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -37,6 +37,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R import io.element.android.libraries.designsystem.components.list.ListItemContent +import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon @@ -239,36 +240,6 @@ private fun CustomStatusInputRow( @PreviewsDayNight @Composable -internal fun UserStatusRowEmptyPreview() = ElementPreview { - UserStatusRow( - state = UserStatusState( - displayedStatus = null, - pickerState = UserStatusPickerState.Hidden, - eventSink = {}, - ) - ) -} - -@PreviewsDayNight -@Composable -internal fun UserStatusRowSetPreview() = ElementPreview { - UserStatusRow( - state = UserStatusState( - displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")), - pickerState = UserStatusPickerState.Hidden, - eventSink = {}, - ) - ) -} - -@PreviewsDayNight -@Composable -internal fun UserStatusRowCustomInputPreview() = ElementPreview { - UserStatusRow( - state = UserStatusState( - displayedStatus = null, - pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState()), - eventSink = {}, - ) - ) +internal fun UserStatusRowPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview { + UserStatusRow(state = state) } From 2a0e41652af273fa7a0d435a943ae7ccdbf39c13 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 16 Jun 2026 12:13:58 +0200 Subject: [PATCH 32/64] Match row heights: add vertical padding to plain text rows to align with TextField row --- .../impl/userstatus/UserStatusView.kt | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 3c30701834..035861f243 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -10,8 +10,9 @@ package io.element.android.features.preferences.impl.userstatus import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.visible import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions @@ -32,12 +33,12 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R import io.element.android.libraries.designsystem.components.list.ListItemContent -import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon @@ -106,7 +107,10 @@ private fun EmptyStatusRow( ) { ListItem( headlineContent = { - Text(text = stringResource(R.string.screen_preferences_user_status_placeholder)) + Text( + text = stringResource(R.string.screen_preferences_user_status_placeholder), + modifier = Modifier.padding(vertical = 16.dp), + ) }, trailingContent = ListItemContent.Custom({ Box(modifier = Modifier.minimumInteractiveComponentSize()) @@ -128,7 +132,7 @@ private fun CurrentStatusRow( is DisplayedStatus.InCall -> "🎧" to stringResource(R.string.common_user_status_on_a_call) } ListItem( - headlineContent = { Text(text = text) }, + headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, leadingContent = ListItemContent.Custom({ Text(text = emoji, modifier = Modifier.size(24.dp)) }), @@ -187,16 +191,15 @@ private fun CustomStatusInputRow( val density = LocalDensity.current val textMeasurer = rememberTextMeasurer() // Measure both labels and derive a stable min-width so the button never resizes. - // 24.dp = 12.dp start + 12.dp end padding of TextButton (Large, no icon). - val minButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) { + // 32.dp = 16.dp start + 16.dp end padding of TextButton (Large, no icon). + val actionButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) { val saveWidth = textMeasurer.measure(saveLabel, textStyle).size.width val cancelWidth = textMeasurer.measure(cancelLabel, textStyle).size.width - with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 24.dp + with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 32.dp } ListItem( headlineContent = { TextField( - modifier = Modifier.fillMaxWidth(), state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), inputTransformation = InputTransformation.maxLength(maxLength = 30), @@ -204,12 +207,14 @@ private fun CustomStatusInputRow( onKeyboardAction = { if (hasText) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, trailingIcon = { - if (hasText) { - Box(modifier = Modifier.clickable { - textFieldState.clearText() - }) { - Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_cancel)) - } + Box(modifier = Modifier.clickable(enabled = hasText) { + textFieldState.clearText() + }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_cancel), + modifier = Modifier.visible(hasText) + ) } } ) @@ -218,7 +223,7 @@ private fun CustomStatusInputRow( TextButton( onClick = if (hasText) onConfirm else onCancel, text = if (hasText) saveLabel else cancelLabel, - modifier = Modifier.widthIn(min = minButtonWidth), + modifier = Modifier.widthIn(min = actionButtonWidth), ) }), leadingContent = ListItemContent.Custom({ From 553a4785e182739c7e629f9e434a8a0375ecd83c Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 11:05:53 +0200 Subject: [PATCH 33/64] Rename Event --- .../impl/userstatus/UserStatusEvent.kt | 15 ++++----------- .../impl/userstatus/UserStatusPresenter.kt | 14 ++++++-------- .../impl/userstatus/UserStatusState.kt | 2 +- .../preferences/impl/userstatus/UserStatusView.kt | 12 ++++++------ .../impl/userstatus/UserStatusPresenterTest.kt | 15 +++++++-------- 5 files changed, 24 insertions(+), 34 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt index 80dd02e08a..815cd12d66 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt @@ -10,18 +10,11 @@ package io.element.android.features.preferences.impl.userstatus import io.element.android.libraries.matrix.api.user.UserStatus sealed interface UserStatusEvent { - /** User tapped the status row — open the picker bottom sheet. */ - data object Open : UserStatusEvent - /** User dismissed the bottom sheet without selecting. */ - data object Dismiss : UserStatusEvent - /** User selected a predefined or confirmed a custom status. */ - data class Set(val status: UserStatus) : UserStatusEvent - /** User tapped "Custom…" in the bottom sheet. */ + data object OpenPicker : UserStatusEvent + data object DismissPicker : UserStatusEvent + data class SetStatus(val status: UserStatus) : UserStatusEvent data object OpenCustomInput : UserStatusEvent - /** User tapped Cancel in the inline custom input row. */ data object CancelCustomInput : UserStatusEvent - /** User changed the emoji in the custom input row. */ data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent - /** User tapped the clear action on an existing status. */ - data object Clear : UserStatusEvent + data object ClearStatus : UserStatusEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index 0bb626b159..384e09862a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -7,8 +7,8 @@ package io.element.android.features.preferences.impl.userstatus -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -18,11 +18,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.user.UserStatus import kotlinx.coroutines.launch @ContributesBinding(SessionScope::class) @@ -34,13 +32,13 @@ class UserStatusPresenter( override fun present(): UserStatusState { val userProfile by matrixClient.userProfile.collectAsState() var pickerState by remember { mutableStateOf(UserStatusPickerState.Hidden) } - val customTextFieldState = remember { TextFieldState() } + val customTextFieldState = rememberTextFieldState() val coroutineScope = rememberCoroutineScope() fun handleEvent(event: UserStatusEvent) { when (event) { - UserStatusEvent.Open -> pickerState = UserStatusPickerState.ShowingPicker - UserStatusEvent.Dismiss -> pickerState = UserStatusPickerState.Hidden + UserStatusEvent.OpenPicker -> pickerState = UserStatusPickerState.ShowingPicker + UserStatusEvent.DismissPicker -> pickerState = UserStatusPickerState.Hidden UserStatusEvent.OpenCustomInput -> { val raw = userProfile.rawStatus if (raw != null) { @@ -53,11 +51,11 @@ class UserStatusPresenter( textFieldState = customTextFieldState, ) } - is UserStatusEvent.Set -> { + is UserStatusEvent.SetStatus -> { pickerState = UserStatusPickerState.Hidden coroutineScope.launch { matrixClient.setUserStatus(event.status) } } - UserStatusEvent.Clear -> { + UserStatusEvent.ClearStatus -> { pickerState = UserStatusPickerState.Hidden coroutineScope.launch { matrixClient.clearUserStatus() } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt index b1fe11d1ed..f79ae86d8b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -22,7 +22,7 @@ sealed interface UserStatusPickerState { data object Hidden : UserStatusPickerState data object ShowingPicker : UserStatusPickerState data class CustomInput( - val emoji: String = "😀", + val emoji: String, val textFieldState: TextFieldState, ) : UserStatusPickerState } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 035861f243..e211fb1110 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -65,20 +65,20 @@ fun UserStatusRow( if (displayedStatus != null) { CurrentStatusRow( displayedStatus = displayedStatus, - onClick = { state.eventSink(UserStatusEvent.Open) }, - onClear = { state.eventSink(UserStatusEvent.Clear) }, + onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, + onClear = { state.eventSink(UserStatusEvent.ClearStatus) }, modifier = modifier, ) } else { EmptyStatusRow( - onClick = { state.eventSink(UserStatusEvent.Open) }, + onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, modifier = modifier, ) } if (pickerState == UserStatusPickerState.ShowingPicker) { UserStatusPickerBottomSheet( - onDismiss = { state.eventSink(UserStatusEvent.Dismiss) }, - onSelectPredefined = { status -> state.eventSink(UserStatusEvent.Set(status)) }, + onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, + onSelectPredefined = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, ) } @@ -90,7 +90,7 @@ fun UserStatusRow( onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, onConfirm = { state.eventSink( - UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) + UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) ) }, onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt index 349361a61b..455aefe180 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -16,7 +16,6 @@ import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.test.FakeMatrixClient import kotlinx.coroutines.test.runTest import org.junit.Test -import androidx.compose.foundation.text.input.TextFieldState class UserStatusPresenterTest { @@ -40,7 +39,7 @@ class UserStatusPresenterTest { createPresenter().present() }.test { val state = awaitItem() - state.eventSink(UserStatusEvent.Open) + state.eventSink(UserStatusEvent.OpenPicker) assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker) } } @@ -51,9 +50,9 @@ class UserStatusPresenterTest { createPresenter().present() }.test { val state = awaitItem() - state.eventSink(UserStatusEvent.Open) + state.eventSink(UserStatusEvent.OpenPicker) awaitItem() // ShowingPicker - state.eventSink(UserStatusEvent.Dismiss) + state.eventSink(UserStatusEvent.DismissPicker) assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden) } } @@ -65,7 +64,7 @@ class UserStatusPresenterTest { createPresenter(client).present() }.test { val status = UserStatus(emoji = "💬", text = "In a meeting") - awaitItem().eventSink(UserStatusEvent.Set(status)) + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) val newState = awaitItem() assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status)) @@ -93,7 +92,7 @@ class UserStatusPresenterTest { createPresenter(client).present() }.test { val status = UserStatus(emoji = "🌴", text = "Away") - awaitItem().eventSink(UserStatusEvent.Set(status)) + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() @@ -133,9 +132,9 @@ class UserStatusPresenterTest { createPresenter(client).present() }.test { val status = UserStatus(emoji = "☕", text = "Be right back") - awaitItem().eventSink(UserStatusEvent.Set(status)) + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition - awaitItem().eventSink(UserStatusEvent.Clear) + awaitItem().eventSink(UserStatusEvent.ClearStatus) val clearedState = awaitItem() assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(clearedState.displayedStatus).isNull() From 1cfd3e4dfb64f2d479098b70f8d06f342a62cb55 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 15:55:49 +0200 Subject: [PATCH 34/64] Improve UI/UX for setting status --- .../impl/userstatus/UserStatusPresenter.kt | 1 + .../impl/userstatus/UserStatusState.kt | 2 + .../userstatus/UserStatusStateProvider.kt | 7 ++ .../impl/userstatus/UserStatusView.kt | 109 ++++++++++++------ .../impl/root/PreferencesRootViewTest.kt | 9 +- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index 384e09862a..e5c22fd568 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -69,6 +69,7 @@ class UserStatusPresenter( return UserStatusState( displayedStatus = userProfile.displayedStatus, + rawStatus = userProfile.rawStatus, pickerState = pickerState, eventSink = ::handleEvent, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt index f79ae86d8b..7cfa03fa81 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -11,9 +11,11 @@ import androidx.annotation.StringRes import androidx.compose.foundation.text.input.TextFieldState import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus data class UserStatusState( val displayedStatus: DisplayedStatus?, + val rawStatus: UserStatus?, val pickerState: UserStatusPickerState, val eventSink: (UserStatusEvent) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt index 7a7a1d4dac..087cb85355 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt @@ -18,6 +18,11 @@ internal class UserStatusStateProvider : PreviewParameterProvider Unit = {}, ) = UserStatusState( displayedStatus = displayedStatus, + rawStatus = rawStatus, pickerState = pickerState, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index e211fb1110..1b0b8a478d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -12,8 +12,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.visible -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation @@ -23,16 +21,22 @@ import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.maxLength import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -46,6 +50,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton @@ -77,6 +82,7 @@ fun UserStatusRow( } if (pickerState == UserStatusPickerState.ShowingPicker) { UserStatusPickerBottomSheet( + currentRawStatus = state.rawStatus, onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, onSelectPredefined = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, @@ -87,6 +93,7 @@ fun UserStatusRow( CustomStatusInputRow( emoji = pickerState.emoji, textFieldState = pickerState.textFieldState, + rawStatus = state.rawStatus, onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, onConfirm = { state.eventSink( @@ -134,7 +141,7 @@ private fun CurrentStatusRow( ListItem( headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, leadingContent = ListItemContent.Custom({ - Text(text = emoji, modifier = Modifier.size(24.dp)) + Text(text = emoji, modifier = Modifier.size(24.dp), textAlign = TextAlign.Center) }), trailingContent = ListItemContent.Custom({ IconButton(onClick = onClear) { @@ -149,19 +156,38 @@ private fun CurrentStatusRow( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun UserStatusPickerBottomSheet( + currentRawStatus: UserStatus?, onDismiss: () -> Unit, onSelectPredefined: (UserStatus) -> Unit, onSelectCustom: () -> Unit, modifier: Modifier = Modifier, ) { - ModalBottomSheet(onDismissRequest = onDismiss, scrollable = true, modifier = modifier) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + scrollable = true, + modifier = modifier, + ) { PredefinedUserStatus.entries.forEach { predefined -> val label = stringResource(predefined.labelRes) + val predefinedUserStatus = UserStatus(emoji = predefined.emoji, text = label) + val isSelected = currentRawStatus == predefinedUserStatus ListItem( headlineContent = { Text(text = label) }, leadingContent = ListItemContent.Text(text = predefined.emoji), + trailingContent = if (isSelected) ListItemContent.Custom({ + Icon( + imageVector = CompoundIcons.Check(), + contentDescription = null, + tint = ElementTheme.colors.iconAccentPrimary, + ) + }) else null, onClick = { - onSelectPredefined(UserStatus(emoji = predefined.emoji, text = label)) + sheetState.hide(coroutineScope) { + onSelectPredefined(predefinedUserStatus) + } }, ) } @@ -170,7 +196,11 @@ private fun UserStatusPickerBottomSheet( Text(text = stringResource(R.string.common_user_status_custom)) }, leadingContent = ListItemContent.Text(text = "✏️"), - onClick = onSelectCustom, + onClick = { + sheetState.hide(coroutineScope) { + onSelectCustom() + } + }, ) } } @@ -179,52 +209,63 @@ private fun UserStatusPickerBottomSheet( private fun CustomStatusInputRow( emoji: String, textFieldState: TextFieldState, + rawStatus: UserStatus?, onEmojiChange: (String) -> Unit, onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, ) { val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } } + val hasChanges by remember(emoji, rawStatus) { + derivedStateOf { + val text = textFieldState.text.toString() + text.isNotBlank() && UserStatus(emoji, text) != rawStatus + } + } val saveLabel = stringResource(CommonStrings.action_save) val cancelLabel = stringResource(CommonStrings.action_cancel) - val textStyle = ElementTheme.typography.fontBodyLgMedium - val density = LocalDensity.current - val textMeasurer = rememberTextMeasurer() - // Measure both labels and derive a stable min-width so the button never resizes. - // 32.dp = 16.dp start + 16.dp end padding of TextButton (Large, no icon). - val actionButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) { - val saveWidth = textMeasurer.measure(saveLabel, textStyle).size.width - val cancelWidth = textMeasurer.measure(cancelLabel, textStyle).size.width - with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 32.dp - } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } ListItem( headlineContent = { TextField( + modifier = Modifier.focusRequester(focusRequester), state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), inputTransformation = InputTransformation.maxLength(maxLength = 30), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - onKeyboardAction = { if (hasText) onConfirm() }, + onKeyboardAction = { if (hasChanges) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, - trailingIcon = { - Box(modifier = Modifier.clickable(enabled = hasText) { - textFieldState.clearText() - }) { - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_cancel), - modifier = Modifier.visible(hasText) - ) + trailingIcon = if (hasText) { + { + Box(modifier = Modifier.clickable { textFieldState.clearText() }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_cancel), + modifier = Modifier.size(22.dp), + ) + } } - } + } else null ) }, trailingContent = ListItemContent.Custom({ - TextButton( - onClick = if (hasText) onConfirm else onCancel, - text = if (hasText) saveLabel else cancelLabel, - modifier = Modifier.widthIn(min = actionButtonWidth), - ) + // Layout measures both buttons to determine the max natural width, + // then places only the active one — no hardcoded padding values needed. + Layout( + content = { + TextButton(onClick = onConfirm, text = saveLabel) + TextButton(onClick = onCancel, text = cancelLabel) + } + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(Constraints()) } + val maxWidth = placeables.maxOf { it.width } + val maxHeight = placeables.maxOf { it.height } + layout(maxWidth, maxHeight) { + if (hasChanges) placeables[0].placeRelative(0, 0) + else placeables[1].placeRelative(0, 0) + } + } }), leadingContent = ListItemContent.Custom({ Surface( diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index 5c9d81875e..bf8bd78bf2 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -202,7 +202,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenAnalytics = callback, ) - clickOn(CommonStrings.common_analytics) + val text = activity!!.getString(CommonStrings.common_analytics) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -229,7 +230,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenRageShake = callback, ) - clickOn(CommonStrings.common_report_a_problem) + val text = activity!!.getString(CommonStrings.common_report_a_problem) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -393,7 +395,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onSignOutClick = callback, ) - clickOn(CommonStrings.action_signout) + val text = activity!!.getString(CommonStrings.action_signout) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } From dd24e14cf61f6f64fa4b139f2b7dad3519acb819 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 17:20:58 +0200 Subject: [PATCH 35/64] Gate user status behind UserStatus feature flag --- .../impl/root/PreferencesRootPresenter.kt | 4 +- .../impl/root/PreferencesRootState.kt | 2 +- .../impl/root/PreferencesRootStateProvider.kt | 2 +- .../impl/root/PreferencesRootView.kt | 39 ++++++++++++++----- .../libraries/featureflag/api/FeatureFlags.kt | 7 ++++ 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index fa69382cf8..3daeb5aec1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -62,20 +62,20 @@ class PreferencesRootPresenter( ) : Presenter { @Composable override fun present(): PreferencesRootState { - val userStatusState = userStatusPresenter.present() val coroutineScope = rememberCoroutineScope() val matrixUser = matrixClient.userProfile.collectAsState() LaunchedEffect(Unit) { // Force a refresh of the profile matrixClient.getUserProfile() } - val isMultiAccountEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount) }.collectAsState(initial = false) val showLinkNewDevice by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) }.collectAsState(initial = false) + val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false) + val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null val otherSessions by remember { sessionStore.sessionsFlow().map { list -> diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index 8163e4b7a0..c415547a80 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -17,7 +17,7 @@ import kotlinx.collections.immutable.ImmutableList data class PreferencesRootState( val myUser: MatrixUser, - val userStatusState: UserStatusState, + val userStatusState: UserStatusState?, val version: String, val deviceId: DeviceId?, val isMultiAccountEnabled: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 9e1557fd7b..1ac2e1f6f5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -88,7 +88,7 @@ fun aPreferencesRootState( nbOfBlockedUsers: Int = 0, showLabsItem: Boolean = false, directLogoutState: DirectLogoutState = aDirectLogoutState(), - userStatusState: UserStatusState = aUserStatusState(), + userStatusState: UserStatusState? = aUserStatusState(), snackbarMessage: SnackbarMessage? = null, eventSink: (PreferencesRootEvent) -> Unit = {}, ) = PreferencesRootState( diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index d92c1e4613..5221c82dda 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -25,6 +25,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.user.UserPreferences import io.element.android.features.preferences.impl.userstatus.UserStatusRow +import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.list.ListItemContent @@ -82,15 +83,14 @@ fun PreferencesRootView( }, matrixUser = state.myUser, ) - HorizontalDivider() - UserStatusRow( - state = state.userStatusState, - modifier = Modifier.fillMaxWidth(), - ) - HorizontalDivider() + + if (state.userStatusState != null) { + UserStatusSection(state.userStatusState) + } if (state.isMultiAccountEnabled) { MultiAccountSection( state = state, + showTopDivider = state.userStatusState == null, onAddAccountClick = onAddAccountClick, ) } @@ -135,14 +135,33 @@ fun PreferencesRootView( } @Composable -private fun ColumnScope.MultiAccountSection( - state: PreferencesRootState, - onAddAccountClick: () -> Unit, -) { +private fun ColumnScope.UserStatusSection(userStatusState: UserStatusState) { HorizontalDivider( thickness = 8.dp, color = ElementTheme.colors.bgSubtleSecondary, ) + UserStatusRow( + state = userStatusState, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) +} + +@Composable +private fun ColumnScope.MultiAccountSection( + state: PreferencesRootState, + showTopDivider: Boolean, + onAddAccountClick: () -> Unit, +) { + if(showTopDivider) { + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + } state.otherSessions.forEach { matrixUser -> MatrixUserRow( modifier = Modifier diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 0127e41c99..b298cd2f16 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -137,4 +137,11 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + UserStatus( + key = "feature.user_status", + title = "User status", + description = "Allow users to set a status (e.g. In a meeting, Away) visible to their contacts.", + defaultValue = { false }, + isFinished = false, + ), } From d29dad40f944baccff300caf725c3a7496d41385 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 17:37:21 +0200 Subject: [PATCH 36/64] Extract custom status text max length into a constant --- .../features/preferences/impl/userstatus/UserStatusView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 1b0b8a478d..a39c72f0d7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -205,6 +205,8 @@ private fun UserStatusPickerBottomSheet( } } +private const val CUSTOM_STATUS_TEXT_MAX_LENGTH = 30 + @Composable private fun CustomStatusInputRow( emoji: String, @@ -232,7 +234,7 @@ private fun CustomStatusInputRow( modifier = Modifier.focusRequester(focusRequester), state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), - inputTransformation = InputTransformation.maxLength(maxLength = 30), + inputTransformation = InputTransformation.maxLength(CUSTOM_STATUS_TEXT_MAX_LENGTH), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), onKeyboardAction = { if (hasChanges) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, From 061fd82c1ad136be8542f19a8109f28c6eca37be Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 17:37:39 +0200 Subject: [PATCH 37/64] Clip avatar to a circle on the user status screen --- .../features/preferences/impl/userstatus/UserStatusView.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index a39c72f0d7..7856be468a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -275,6 +276,7 @@ private fun CustomStatusInputRow( border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary), modifier = Modifier .size(50.dp) + .clip(CircleShape) .clickable { }, ) { Box(contentAlignment = Alignment.Center) { From be872e6becb360141d4c36c0c86031ba3fd3926d Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 18 Jun 2026 16:42:22 +0200 Subject: [PATCH 38/64] Move user status section below multi-account in preferences --- .../impl/root/PreferencesRootStateProvider.kt | 6 ++- .../impl/root/PreferencesRootView.kt | 48 +++++++++---------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 1ac2e1f6f5..8e931d37be 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -11,11 +11,14 @@ package io.element.android.features.preferences.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.preferences.impl.userstatus.PredefinedUserStatus +import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.ui.strings.CommonStrings @@ -66,8 +69,9 @@ open class PreferencesRootStateProvider : PreviewParameterProvider Unit, ) { if(showTopDivider) { HorizontalDivider( @@ -162,6 +143,25 @@ private fun ColumnScope.MultiAccountSection( color = ElementTheme.colors.bgSubtleSecondary, ) } + UserStatusRow( + state = userStatusState, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider( + thickness = 1.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) +} + +@Composable +private fun ColumnScope.MultiAccountSection( + state: PreferencesRootState, + onAddAccountClick: () -> Unit, +) { + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) state.otherSessions.forEach { matrixUser -> MatrixUserRow( modifier = Modifier From 4a143c10537a3c7b1e9b2e8193179371b1b16297 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 18 Jun 2026 20:22:09 +0200 Subject: [PATCH 39/64] Format code --- .../impl/root/PreferencesRootStateProvider.kt | 2 -- .../impl/root/PreferencesRootView.kt | 2 +- .../impl/userstatus/UserStatusPresenter.kt | 1 - .../impl/userstatus/UserStatusView.kt | 29 ++++++++++++------- .../userstatus/UserStatusPresenterTest.kt | 1 - .../libraries/matrix/api/MatrixClient.kt | 1 + 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 8e931d37be..0abed1f66f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -11,14 +11,12 @@ package io.element.android.features.preferences.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState -import io.element.android.features.preferences.impl.userstatus.PredefinedUserStatus import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.ui.strings.CommonStrings diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 5baf01d960..324f0a9773 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -137,7 +137,7 @@ private fun ColumnScope.UserStatusSection( userStatusState: UserStatusState, showTopDivider: Boolean, ) { - if(showTopDivider) { + if (showTopDivider) { HorizontalDivider( thickness = 8.dp, color = ElementTheme.colors.bgSubtleSecondary, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index e5c22fd568..fac82d4505 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -27,7 +27,6 @@ import kotlinx.coroutines.launch class UserStatusPresenter( private val matrixClient: MatrixClient, ) : Presenter { - @Composable override fun present(): UserStatusState { val userProfile by matrixClient.userProfile.collectAsState() diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 7856be468a..f7d37f0adf 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -23,22 +23,22 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons @@ -51,11 +51,11 @@ import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet -import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @@ -178,13 +178,17 @@ private fun UserStatusPickerBottomSheet( ListItem( headlineContent = { Text(text = label) }, leadingContent = ListItemContent.Text(text = predefined.emoji), - trailingContent = if (isSelected) ListItemContent.Custom({ + trailingContent = if (isSelected) { + ListItemContent.Custom({ Icon( imageVector = CompoundIcons.Check(), contentDescription = null, tint = ElementTheme.colors.iconAccentPrimary, ) - }) else null, + }) + } else { + null + }, onClick = { sheetState.hide(coroutineScope) { onSelectPredefined(predefinedUserStatus) @@ -249,7 +253,9 @@ private fun CustomStatusInputRow( ) } } - } else null + } else { + null + } ) }, trailingContent = ListItemContent.Custom({ @@ -265,8 +271,11 @@ private fun CustomStatusInputRow( val maxWidth = placeables.maxOf { it.width } val maxHeight = placeables.maxOf { it.height } layout(maxWidth, maxHeight) { - if (hasChanges) placeables[0].placeRelative(0, 0) - else placeables[1].placeRelative(0, 0) + if (hasChanges) { + placeables[0].placeRelative(0, 0) + } else { + placeables[1].placeRelative(0, 0) + } } } }), diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt index 455aefe180..4a03a22607 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Test class UserStatusPresenterTest { - private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) = UserStatusPresenter(matrixClient = client) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 001bac0d30..e7d010d510 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -86,6 +86,7 @@ interface MatrixClient { suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result suspend fun removeAvatar(): Result suspend fun setUserStatus(status: UserStatus): Result + /** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */ suspend fun clearUserStatus(): Result suspend fun joinRoom(roomId: RoomId): Result From 29b8068d4a335f2dd5c73a693d8010b1ae0df727 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 19 Jun 2026 15:14:37 +0200 Subject: [PATCH 40/64] Address quality issues on user status --- .../impl/root/PreferencesRootView.kt | 4 ++-- .../userstatus/UserStatusBindingContainer.kt | 21 +++++++++++++++++++ .../impl/userstatus/UserStatusPresenter.kt | 5 ++--- .../impl/userstatus/UserStatusState.kt | 4 +++- .../impl/userstatus/UserStatusView.kt | 20 ++++++++---------- .../matrix/api/user/DisplayedStatus.kt | 2 ++ .../libraries/matrix/impl/RustMatrixClient.kt | 1 + 7 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusBindingContainer.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 324f0a9773..8f272ea8ae 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -24,8 +24,8 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.user.UserPreferences -import io.element.android.features.preferences.impl.userstatus.UserStatusRow import io.element.android.features.preferences.impl.userstatus.UserStatusState +import io.element.android.features.preferences.impl.userstatus.UserStatusView import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.list.ListItemContent @@ -143,7 +143,7 @@ private fun ColumnScope.UserStatusSection( color = ElementTheme.colors.bgSubtleSecondary, ) } - UserStatusRow( + UserStatusView( state = userStatusState, modifier = Modifier.fillMaxWidth(), ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusBindingContainer.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusBindingContainer.kt new file mode 100644 index 0000000000..dfa77483f8 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusBindingContainer.kt @@ -0,0 +1,21 @@ +/* + * 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.features.preferences.impl.userstatus + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@BindingContainer +interface UserStatusBindingContainer { + @Binds + fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index fac82d4505..ea09f59706 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -17,13 +17,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import kotlinx.coroutines.launch -@ContributesBinding(SessionScope::class) +@Inject class UserStatusPresenter( private val matrixClient: MatrixClient, ) : Presenter { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt index 7cfa03fa81..fa61089144 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus import androidx.annotation.StringRes import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Immutable import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus @@ -20,6 +21,7 @@ data class UserStatusState( val eventSink: (UserStatusEvent) -> Unit, ) +@Immutable sealed interface UserStatusPickerState { data object Hidden : UserStatusPickerState data object ShowingPicker : UserStatusPickerState @@ -29,7 +31,7 @@ sealed interface UserStatusPickerState { ) : UserStatusPickerState } -enum class PredefinedUserStatus(val emoji: String, @param:StringRes val labelRes: Int) { +enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) { IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting), FOCUS_TIME("💡", R.string.common_user_status_focus_time), ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road), diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index f7d37f0adf..245052120c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -61,7 +61,7 @@ import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @Composable -fun UserStatusRow( +fun UserStatusView( state: UserStatusState, modifier: Modifier = Modifier, ) { @@ -85,8 +85,8 @@ fun UserStatusRow( UserStatusPickerBottomSheet( currentRawStatus = state.rawStatus, onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, - onSelectPredefined = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, - onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, + onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, + onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) }, ) } } @@ -95,7 +95,6 @@ fun UserStatusRow( emoji = pickerState.emoji, textFieldState = pickerState.textFieldState, rawStatus = state.rawStatus, - onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, onConfirm = { state.eventSink( UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) @@ -159,8 +158,8 @@ private fun CurrentStatusRow( private fun UserStatusPickerBottomSheet( currentRawStatus: UserStatus?, onDismiss: () -> Unit, - onSelectPredefined: (UserStatus) -> Unit, - onSelectCustom: () -> Unit, + onSelectPredefinedStatus: (UserStatus) -> Unit, + onSelectCustomStatus: () -> Unit, modifier: Modifier = Modifier, ) { val sheetState = rememberModalBottomSheetState() @@ -191,7 +190,7 @@ private fun UserStatusPickerBottomSheet( }, onClick = { sheetState.hide(coroutineScope) { - onSelectPredefined(predefinedUserStatus) + onSelectPredefinedStatus(predefinedUserStatus) } }, ) @@ -203,7 +202,7 @@ private fun UserStatusPickerBottomSheet( leadingContent = ListItemContent.Text(text = "✏️"), onClick = { sheetState.hide(coroutineScope) { - onSelectCustom() + onSelectCustomStatus() } }, ) @@ -217,7 +216,6 @@ private fun CustomStatusInputRow( emoji: String, textFieldState: TextFieldState, rawStatus: UserStatus?, - onEmojiChange: (String) -> Unit, onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, @@ -299,6 +297,6 @@ private fun CustomStatusInputRow( @PreviewsDayNight @Composable -internal fun UserStatusRowPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview { - UserStatusRow(state = state) +internal fun UserStatusViewPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview { + UserStatusView(state = state) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt index 046191d7cf..581658ff28 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt @@ -8,8 +8,10 @@ package io.element.android.libraries.matrix.api.user import android.os.Parcelable +import androidx.compose.runtime.Immutable import kotlinx.parcelize.Parcelize +@Immutable sealed interface DisplayedStatus : Parcelable { /** Status set manually by the user via m.status. */ @Parcelize diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 92107f3ac2..4d40fa2123 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -138,6 +138,7 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService +@Suppress("LargeClass") class RustMatrixClient( override val sessionPaths: SessionPaths, private val innerClient: Client, From f9a8acf4a55c1bd7f467b1a3f2dc438a609b2498 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 25 Jun 2026 10:50:51 +0000 Subject: [PATCH 41/64] Update screenshots --- ...res.preferences.impl.root_PreferencesRootViewDark_0_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_1_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_2_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_3_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_4_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_5_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_6_en.png | 3 +++ ...es.preferences.impl.root_PreferencesRootViewLight_0_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_1_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_2_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_3_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_4_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_5_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_6_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_0_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_1_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_2_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_3_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_4_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_5_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_6_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_0_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_1_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_2_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_3_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_4_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_5_en.png | 3 +++ ....preferences.impl.userstatus_UserStatusView_Night_6_en.png | 3 +++ 28 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png index 6e46211c3e..4e4f3b0fd3 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cff89dace11868ae57ad7c701303ab19096be52bbc21f7b43f91097fe8a612f1 -size 40300 +oid sha256:4b3d5527423e0bb16a65fc9981db06b7e5eaef2736e68833dd91aae5825b9126 +size 40439 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png index 053b0cf1f6..312a9bad45 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:92420711d5445edf625ff3125ecabbfbd4317dda6d7e29b4484a3b38d8d3193d -size 24297 +oid sha256:941f79f738e902fcca7e3ec2f71acc4c230532bdb2c9136a2e7420ac88106ba4 +size 27372 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png index 2c9cc8de20..32610f27d2 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f36ead42cf5f79023ca960698b25864e44a098b71d4de8a4fbff6e2cb0a86b99 -size 35546 +oid sha256:dc80aa470630ec356274dec6abd0f05149506f4bb1c1b495a4ad8ee4efc8ac69 +size 38510 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png index 2d0012240f..08dc4d97cc 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:83085cec3cd4e3a8eb9c8b112d54d9aa1eaa6ca3206b5f526b6e0edc7392ce92 -size 26743 +oid sha256:a89c8eeebf6cba350daa4940ea8248148286341bf19a4b36f63330ab508bfa20 +size 29985 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png index a89b3d05f5..9c32e85b15 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a9739c8908859f2401d118e32f4354a2132d6d08173c4e5683eaa1b8dee8cc5 -size 26897 +oid sha256:b5743d92c92425093ddeff2af31d8c3a803a029711aed1712c7cbc50890b0d77 +size 29992 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png index d5f5b7f6ca..423bca1a33 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0d9335e3b6900a366d4770849736b7bc866bb205c9b6f004a3521d6a058916b -size 20020 +oid sha256:cfa5e8a6baef9873454a43857cfa7983ef64dc65a0ff8649ccc96ac1dac7f999 +size 32775 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_6_en.png new file mode 100644 index 0000000000..273cf6c781 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:837ebeb30aebfb154e98b1b4603156cf0ac877b58efae17d534aaf269ce8ba10 +size 20037 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png index d527233adc..a1906506ce 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a911c0f75b2146fffb24d3bddac02ae096eac871285ef15c4d1477629a5b2d49 -size 41390 +oid sha256:50da5b2b2851c4b13daadc44e81beaf19294985fb4c6e37caf2105f0bddc6bb6 +size 41628 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png index 77170a40ca..8b41f74ad9 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dee0eff6123dca9f9b3ba544d2db16b1e2ab982d20a5d8a0013b0e08e37afddd -size 25094 +oid sha256:17fc2a4024aec430ca302e915f951c231d6f8c66de9030bc8acf64954cbd8d8b +size 28424 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png index 7be86a8757..09a752bb66 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6fef2b0dd09457de0079e3c00ea6e07a81bfb7d0f500d228d38c9054b446708f -size 36271 +oid sha256:b766642d069f44c06f7a2bf74dba24070fc3260cb77cc9658d163d971b9573d4 +size 39408 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png index 046163551a..a95a91b731 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a8ef73ae13ff39f1612dde7582225a18656015c2608fa45510bef3d8a52a705 -size 27701 +oid sha256:db069c64c97811f2b311bb63c8dbb9f78f534bdb3475d7b433412350445ec894 +size 30937 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png index 1c8583ea33..ffa813f1ac 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:691bdc37a68ad14bf1ac08ff242861412915c597017b7980850fb2a82070c228 -size 27646 +oid sha256:66f3f2d1f043603f36187c2ea1e86f40628b23f0aed69f90e2452dbbae558133 +size 30888 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png index 5c6556e3b3..83b748ebff 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41c88c94e910f524567d7054d7914eeddccff2b0249e331582ea1ab130d40d6a -size 20454 +oid sha256:0b82b065f1ea1dda8a159b5688c803ba2f7c7e1d60a31e51a9353a8ec1928321 +size 34166 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_6_en.png new file mode 100644 index 0000000000..d77f3adbf9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07ad7e092d6b8e41064bcbce131ef53d70a552dd6f9292dd286dd7c10948cf9a +size 20459 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_0_en.png new file mode 100644 index 0000000000..d98e3fd475 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c14e7ad5b7c320873dab0b97a6261b2b62a7405d133e9c53fe2184e9757becac +size 9203 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png new file mode 100644 index 0000000000..33a7b53d6d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82cf75a0887b9482cd80e04b3c60b588930a0076571b4f5f7ea8247dc525a912 +size 7114 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png new file mode 100644 index 0000000000..58c1aa1c27 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c1b2766c7c6c3a27484944d772da87cd2b7acc42f25fd21f19bae6dffea204c +size 7232 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png new file mode 100644 index 0000000000..7f8cabb58b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c04a8ab290295a792e3b78d7155be66618aa3d054eb1009431f3d7ae9746f053 +size 28895 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png new file mode 100644 index 0000000000..6d8d5388c4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6de59d47eeefb8c03c9ed0694c67b1666eb35cb7f178c57a52041a19b0398fde +size 27426 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png new file mode 100644 index 0000000000..f96db19f31 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:232de960e0b5aa52d824dabdd33b544847d940054b1f90197d48cb3160a7d818 +size 12079 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png new file mode 100644 index 0000000000..0fd9e3adb4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b44a0abf28f2b88bfc8d57e4b3c7ab248840f41190a389ac3c7b31e58202235 +size 11995 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_0_en.png new file mode 100644 index 0000000000..00a43265e2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:343ddb8692135fb051766c340a4ac6143a5dc57917f0cc16fe11678f2b9bae6e +size 8774 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png new file mode 100644 index 0000000000..687070419e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4311673b25a516c05ca019b72604deb2115a36195d987c116b47f94d9220d82 +size 6736 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png new file mode 100644 index 0000000000..ea9751bafc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8e4c37ef03cdf83319be9ad61a8ff6c91a6a33404680e66d81736563c2a2ec9 +size 6872 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png new file mode 100644 index 0000000000..0b5876ab7e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ec521e4dcc594b59eb159aee39141f1dd94ba10fcacfaa33159c4280b8e22ea +size 27739 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png new file mode 100644 index 0000000000..696fea1cf9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:168aef1da5c35f252d49f77866608a010c0ce42c1c1f6daa2ca0f115a5b5e3cf +size 26228 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png new file mode 100644 index 0000000000..15ea618fb7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:260a44e1132a30150bd94b4232750b768cd1e74b1b149dc1e2d2261061ebf08d +size 11721 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png new file mode 100644 index 0000000000..0dbf6deb0d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27297084473b61e5dc49ab399785433e73caaada5c29684d346e982a2476077d +size 11428 From 4efc82fa74ef7abe36417605c55f04ee2c8787e6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 1 Jul 2026 17:29:09 +0200 Subject: [PATCH 42/64] A11y: Add programmatic label and keyboard focus indicator to PIN entry field Fixes: https://github.com/element-hq/element-x-android/issues/6391 Fixes: https://github.com/element-hq/element-x-android/issues/6401 Co-Authored-By: Claude Sonnet 4.6 --- .../impl/components/PinEntryTextField.kt | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index 439c6aeaa7..dbbabb390c 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -22,8 +22,18 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -32,6 +42,8 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.pinDigitBg +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun PinEntryTextField( @@ -40,15 +52,21 @@ fun PinEntryTextField( onValueChange: (String) -> Unit, modifier: Modifier = Modifier, ) { + var isFocused by remember { mutableStateOf(false) } + val filledCount = pinEntry.digits.count { it is PinDigit.Filled } + val pinFieldLabel = stringResource(CommonStrings.a11y_pin_field) + val digitsEnteredLabel = pluralStringResource(CommonPlurals.a11y_digits_entered, filledCount, filledCount) BasicTextField( - modifier = modifier, + modifier = modifier + .onFocusChanged { isFocused = it.isFocused } + .semantics { contentDescription = "$pinFieldLabel, $digitsEnteredLabel" }, value = pinEntry.toText(), onValueChange = { onValueChange(it) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), decorationBox = { - PinEntryRow(pinEntry = pinEntry, isSecured = isSecured) + PinEntryRow(pinEntry = pinEntry, isSecured = isSecured, isFocused = isFocused) } ) } @@ -58,8 +76,14 @@ fun PinEntryTextField( private fun PinEntryRow( pinEntry: PinEntry, isSecured: Boolean, + isFocused: Boolean = false, ) { FlowRow( + modifier = Modifier.border( + width = 2.dp, + color = if (isFocused) ElementTheme.colors.borderInteractiveHovered else Color.Transparent, + shape = RoundedCornerShape(8.dp), + ), horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(8.dp), ) { From 416da32c4066715fd8f34801c4a9d39405c30447 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 2 Jul 2026 13:38:25 +0200 Subject: [PATCH 43/64] Set focused state only on the digit. --- .../impl/components/PinEntryTextField.kt | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index dbbabb390c..8014504950 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -28,12 +28,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -59,7 +58,9 @@ fun PinEntryTextField( BasicTextField( modifier = modifier .onFocusChanged { isFocused = it.isFocused } - .semantics { contentDescription = "$pinFieldLabel, $digitsEnteredLabel" }, + .clearAndSetSemantics { + contentDescription = "$pinFieldLabel, $digitsEnteredLabel" + }, value = pinEntry.toText(), onValueChange = { onValueChange(it) @@ -76,19 +77,18 @@ fun PinEntryTextField( private fun PinEntryRow( pinEntry: PinEntry, isSecured: Boolean, - isFocused: Boolean = false, + isFocused: Boolean, ) { FlowRow( - modifier = Modifier.border( - width = 2.dp, - color = if (isFocused) ElementTheme.colors.borderInteractiveHovered else Color.Transparent, - shape = RoundedCornerShape(8.dp), - ), horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - for (digit in pinEntry.digits) { - PinDigitView(digit = digit, isSecured = isSecured) + pinEntry.digits.forEachIndexed { index, digit -> + PinDigitView( + digit = digit, + isSecured = isSecured, + isFocused = isFocused && index == pinEntry.digits.indexOfFirst { it is PinDigit.Empty } + ) } } } @@ -97,11 +97,16 @@ private fun PinEntryRow( private fun PinDigitView( digit: PinDigit, isSecured: Boolean, + isFocused: Boolean, ) { val shape = RoundedCornerShape(8.dp) val appearanceModifier = when (digit) { PinDigit.Empty -> { - Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape) + if (isFocused) { + Modifier.border(2.dp, ElementTheme.colors.borderFocused, shape) + } else { + Modifier.border(1.dp, ElementTheme.colors.iconSecondary, shape) + } } is PinDigit.Filled -> { Modifier.background(ElementTheme.colors.pinDigitBg, shape) From 25716f6f3a257aa3058cab97b0da705b9028b211 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 2 Jul 2026 15:33:20 +0200 Subject: [PATCH 44/64] Fix regression --- .../features/lockscreen/impl/components/PinEntryTextField.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index 8014504950..2feaf5f01e 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -105,7 +105,7 @@ private fun PinDigitView( if (isFocused) { Modifier.border(2.dp, ElementTheme.colors.borderFocused, shape) } else { - Modifier.border(1.dp, ElementTheme.colors.iconSecondary, shape) + Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape) } } is PinDigit.Filled -> { From bf1ace48f2ea062391cd78175abdacaf53ce83d0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 2 Jul 2026 15:38:53 +0200 Subject: [PATCH 45/64] Improve preview. --- .../impl/components/PinEntryTextField.kt | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index 2feaf5f01e..bbdd5ce038 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -50,8 +49,9 @@ fun PinEntryTextField( isSecured: Boolean, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, + initialIsFocus: Boolean = false, ) { - var isFocused by remember { mutableStateOf(false) } + var isFocused by remember { mutableStateOf(initialIsFocus) } val filledCount = pinEntry.digits.count { it is PinDigit.Filled } val pinFieldLabel = stringResource(CommonStrings.a11y_pin_field) val digitsEnteredLabel = pluralStringResource(CommonPlurals.a11y_digits_entered, filledCount, filledCount) @@ -137,18 +137,19 @@ private fun PinDigitView( internal fun PinEntryTextFieldPreview() { ElementPreview { val pinEntry = PinEntry.createEmpty(4).fillWith("12") - Column { - PinEntryTextField( - pinEntry = pinEntry, - isSecured = true, - onValueChange = {}, - ) - Spacer(modifier = Modifier.size(16.dp)) - PinEntryTextField( - pinEntry = pinEntry, - isSecured = false, - onValueChange = {}, - ) + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(true, false).forEach { isSecured -> + listOf(true, false).forEach { initialIsFocus -> + PinEntryTextField( + pinEntry = pinEntry, + isSecured = isSecured, + initialIsFocus = initialIsFocus, + onValueChange = {}, + ) + } + } } } } From bfe21698f21e3a927478861f31c497ae3fd4f819 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 2 Jul 2026 18:04:43 +0200 Subject: [PATCH 46/64] Tweak some UserStatusView ui --- .../impl/userstatus/UserStatusView.kt | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 245052120c..dfc68ff446 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -10,8 +10,10 @@ package io.element.android.features.preferences.impl.userstatus import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation @@ -34,11 +36,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons @@ -140,12 +142,17 @@ private fun CurrentStatusRow( } ListItem( headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, - leadingContent = ListItemContent.Custom({ - Text(text = emoji, modifier = Modifier.size(24.dp), textAlign = TextAlign.Center) - }), + leadingContent = ListItemContent.Custom { EmojiText(emoji) }, trailingContent = ListItemContent.Custom({ - IconButton(onClick = onClear) { - Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + IconButton( + onClick = onClear, + // Remove the extra padding caused by IconButton + modifier = Modifier.offset(x = 12.dp) + ) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear), + ) } }), onClick = onClick, @@ -153,6 +160,18 @@ private fun CurrentStatusRow( ) } +@Composable +private fun EmojiText(emoji: String) { + // Ensure the emoji doesn't scale with fontSize to match icons size + val fontSize = with(LocalDensity.current) { 16.dp.toSp() } + Text( + modifier = Modifier.sizeIn(minWidth = 24.dp, minHeight = 24.dp), + text = emoji, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSize), + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun UserStatusPickerBottomSheet( @@ -176,15 +195,9 @@ private fun UserStatusPickerBottomSheet( val isSelected = currentRawStatus == predefinedUserStatus ListItem( headlineContent = { Text(text = label) }, - leadingContent = ListItemContent.Text(text = predefined.emoji), + leadingContent = ListItemContent.Custom { EmojiText(predefined.emoji) }, trailingContent = if (isSelected) { - ListItemContent.Custom({ - Icon( - imageVector = CompoundIcons.Check(), - contentDescription = null, - tint = ElementTheme.colors.iconAccentPrimary, - ) - }) + ListItemContent.Icon(IconSource.Vector(CompoundIcons.Check()), tintColor = ElementTheme.colors.iconAccentPrimary) } else { null }, @@ -199,7 +212,7 @@ private fun UserStatusPickerBottomSheet( headlineContent = { Text(text = stringResource(R.string.common_user_status_custom)) }, - leadingContent = ListItemContent.Text(text = "✏️"), + leadingContent = ListItemContent.Custom { EmojiText("✏️") }, onClick = { sheetState.hide(coroutineScope) { onSelectCustomStatus() @@ -258,22 +271,22 @@ private fun CustomStatusInputRow( }, trailingContent = ListItemContent.Custom({ // Layout measures both buttons to determine the max natural width, - // then places only the active one — no hardcoded padding values needed. + // then places only the active one. Layout( content = { TextButton(onClick = onConfirm, text = saveLabel) TextButton(onClick = onCancel, text = cancelLabel) } ) { measurables, constraints -> - val placeables = measurables.map { it.measure(Constraints()) } + val placeables = measurables.map { it.measure(constraints) } val maxWidth = placeables.maxOf { it.width } val maxHeight = placeables.maxOf { it.height } layout(maxWidth, maxHeight) { - if (hasChanges) { - placeables[0].placeRelative(0, 0) - } else { - placeables[1].placeRelative(0, 0) - } + val active = if (hasChanges) placeables[0] else placeables[1] + active.placeRelative( + x = (maxWidth - active.width) / 2, + y = (maxHeight - active.height) / 2, + ) } } }), @@ -287,7 +300,7 @@ private fun CustomStatusInputRow( .clickable { }, ) { Box(contentAlignment = Alignment.Center) { - Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) + EmojiText(emoji) } } }), From 301f930bcc55e053c7a208bd347db32eb0ea3bb4 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 2 Jul 2026 17:55:54 +0000 Subject: [PATCH 47/64] Update screenshots --- ...res.preferences.impl.root_PreferencesRootViewDark_5_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_5_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_1_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_2_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_3_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_4_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_5_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_6_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_1_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_2_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_3_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_4_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_5_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_6_en.png | 4 ++-- 14 files changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png index 423bca1a33..f6b76e38e7 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cfa5e8a6baef9873454a43857cfa7983ef64dc65a0ff8649ccc96ac1dac7f999 -size 32775 +oid sha256:7c75ad887209b88e700c6ad53090ed4c2d7e5a3f49836608999e9a3124a63fc0 +size 32869 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png index 83b748ebff..13957a0450 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b82b065f1ea1dda8a159b5688c803ba2f7c7e1d60a31e51a9353a8ec1928321 -size 34166 +oid sha256:f12abebdcc42404c0a5719c490f5da4d3d5a1986d469884773bf9ed8071dd27b +size 34154 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png index 33a7b53d6d..0607484ce0 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82cf75a0887b9482cd80e04b3c60b588930a0076571b4f5f7ea8247dc525a912 -size 7114 +oid sha256:35f0fb99a74477ebfa53a019ded68043035de9ba0f86a6a71b032137a125d107 +size 7168 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png index 58c1aa1c27..9f77167b6e 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c1b2766c7c6c3a27484944d772da87cd2b7acc42f25fd21f19bae6dffea204c -size 7232 +oid sha256:6432133a87271013cab796baa7d48ab8e96f65f049b92a9074ec03f540e11545 +size 7269 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png index 7f8cabb58b..470324716e 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c04a8ab290295a792e3b78d7155be66618aa3d054eb1009431f3d7ae9746f053 -size 28895 +oid sha256:ebc88a04da18e07b5f94cc52f75ad033fa01917de6c32ac77a1b400fd27c62cd +size 28944 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png index 6d8d5388c4..56c7646b1b 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6de59d47eeefb8c03c9ed0694c67b1666eb35cb7f178c57a52041a19b0398fde -size 27426 +oid sha256:3cff5ee1c0cc217b20c68bb4f4d2335ab03e9dd8d16045550358e748002c538b +size 27460 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png index f96db19f31..1f73455d54 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:232de960e0b5aa52d824dabdd33b544847d940054b1f90197d48cb3160a7d818 -size 12079 +oid sha256:b5dc5c492ed3fd552e77c0be85b639d55bc7e976f15b761f927b0b80ecca601d +size 12115 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png index 0fd9e3adb4..0ca6f66438 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b44a0abf28f2b88bfc8d57e4b3c7ab248840f41190a389ac3c7b31e58202235 -size 11995 +oid sha256:5896eb5df6f5398bdb94e74280d0d5c04b6a5a53c2378b7377c60fa58eef544b +size 12013 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png index 687070419e..b6c2706180 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4311673b25a516c05ca019b72604deb2115a36195d987c116b47f94d9220d82 -size 6736 +oid sha256:b4b5eb36080cc5750c4cd36ae66ba618c033e794fdf0a4fb5afbae5e1fa10824 +size 6789 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png index ea9751bafc..0b65cb998b 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8e4c37ef03cdf83319be9ad61a8ff6c91a6a33404680e66d81736563c2a2ec9 -size 6872 +oid sha256:bf614dc03afdb990baae40b020bd3f3d6396226b45fc4eee18f2d55100df5bff +size 6918 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png index 0b5876ab7e..2aca39dc8d 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ec521e4dcc594b59eb159aee39141f1dd94ba10fcacfaa33159c4280b8e22ea -size 27739 +oid sha256:fff529404e3219d00ded2f701066c48506b96512b08b1750665d3c2c0a75ed97 +size 27700 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png index 696fea1cf9..5ff2f9b93d 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:168aef1da5c35f252d49f77866608a010c0ce42c1c1f6daa2ca0f115a5b5e3cf -size 26228 +oid sha256:1017de34abdb57805946efdd6d486ab76adecdc458d17eb9b1128be0dbe8ff00 +size 26227 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png index 15ea618fb7..a687241151 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:260a44e1132a30150bd94b4232750b768cd1e74b1b149dc1e2d2261061ebf08d -size 11721 +oid sha256:e7ce8a8c6b4d1692e4b127b7f17f507552666c631893025082c385b93d835a73 +size 11726 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png index 0dbf6deb0d..a1d1c5ec8e 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:27297084473b61e5dc49ab399785433e73caaada5c29684d346e982a2476077d -size 11428 +oid sha256:8b93c08531efbeacb55f84b17ac793a7b226e5a2db7bc5072e94bc040cd1e9bc +size 11386 From 06d778859c7187e372a71f5027d3ca4f7945d8e9 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 3 Jul 2026 07:58:47 +0000 Subject: [PATCH 48/64] Update screenshots --- ....lockscreen.impl.components_PinEntryTextField_Day_0_en.png | 4 ++-- ...ockscreen.impl.components_PinEntryTextField_Night_0_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Day_0_en.png index 3ab1c052d2..3a22d6959f 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76bb68d4ee420ec14a8c9372225c91407ab452479e0897c374721eb834dc6ba0 -size 9920 +oid sha256:2046f1225b2f58812502196f4b7d1c7a4d87f740b32e40dd82762fade1c42ea2 +size 16167 diff --git a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Night_0_en.png index 18a2ab9466..10223f5bf6 100644 --- a/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.lockscreen.impl.components_PinEntryTextField_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d72cda6587357da35dd5a22ff595dd0f9500dac14cf731a348e209bb17ed7b56 -size 9979 +oid sha256:62384687c273cb57dbd25bfaa558d83e3efc527fc4dfff4aca8bf85ab1ce9e4b +size 16446 From bad39d62e998170920943bb2c0a6ed58febfc81a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 3 Jul 2026 09:59:54 +0200 Subject: [PATCH 49/64] Optimize code --- .../features/lockscreen/impl/components/PinEntryTextField.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt index bbdd5ce038..28e005fd1d 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -83,11 +83,12 @@ private fun PinEntryRow( horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + val focusedIndex = pinEntry.digits.indexOfFirst { it is PinDigit.Empty } pinEntry.digits.forEachIndexed { index, digit -> PinDigitView( digit = digit, isSecured = isSecured, - isFocused = isFocused && index == pinEntry.digits.indexOfFirst { it is PinDigit.Empty } + isFocused = isFocused && index == focusedIndex ) } } From 387022a9af91290525489ed3295035c681a98b54 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Jul 2026 14:24:52 +0200 Subject: [PATCH 50/64] Use color from theme and improve preview of EmojiItem --- .../components/customreaction/EmojiItem.kt | 38 ++++++++++++------- .../customreaction/picker/SkinTonePicker.kt | 16 ++++---- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index d934f53bfb..db5967e9a4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.CircleShape @@ -56,6 +57,7 @@ import io.element.android.features.messages.impl.timeline.components.customreact import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePicker import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSize import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSpacing +import io.element.android.features.messages.impl.timeline.components.customreaction.picker.aSkinList import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Text @@ -219,6 +221,7 @@ fun EmojiItem( style = LocalTextStyle.current.copy(fontSize = emojiSize), ) if (hasSkinTones) { + val pickerIndicatorColor = ElementTheme.colors.borderInteractivePrimary Canvas( modifier = Modifier .align(Alignment.BottomEnd) @@ -230,7 +233,7 @@ fun EmojiItem( lineTo(0f, size.height) close() } - drawPath(path, color = Color.Gray.copy(alpha = 0.5f), style = Fill) + drawPath(path, color = pickerIndicatorColor, style = Fill) } } @@ -260,21 +263,28 @@ fun EmojiItem( @Composable internal fun EmojiItemPreview() = ElementPreview { Row( + modifier = Modifier.padding(4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - for (isSelected in listOf(true, false)) { - EmojiItem( - item = Emoji( - hexcode = "", - label = "", - tags = null, - shortcodes = persistentListOf(), - unicode = "👍", - skins = null - ), - isSelected = isSelected, - onSelectEmoji = {}, - ) + for (isSelected in listOf(false, true)) { + for (skinList in listOf(persistentListOf(), aSkinList())) { + val hasSelectedSkinValues = if (skinList.isNotEmpty()) listOf(false, true) else listOf(false) + for (hasSelectedSkin in hasSelectedSkinValues) { + EmojiItem( + item = Emoji( + hexcode = "", + label = "", + tags = null, + shortcodes = persistentListOf(), + unicode = "👍", + skins = skinList, + ), + isSelected = isSelected, + onSelectEmoji = {}, + hasSelectedSkin = hasSelectedSkin, + ) + } + } } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt index c49b07023f..56540e8251 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt @@ -120,14 +120,16 @@ internal fun SkinTonePickerPreview() = ElementPreview { tags = null, shortcodes = persistentListOf("+1", "thumbsup"), unicode = "👍", - skins = persistentListOf( - EmojiSkin("1F44D-1F3FB", "thumbs up: light skin tone", "👍🏻"), - EmojiSkin("1F44D-1F3FC", "thumbs up: medium-light skin tone", "👍🏼"), - EmojiSkin("1F44D-1F3FD", "thumbs up: medium skin tone", "👍🏽"), - EmojiSkin("1F44D-1F3FE", "thumbs up: medium-dark skin tone", "👍🏾"), - EmojiSkin("1F44D-1F3FF", "thumbs up: dark skin tone", "👍🏿"), - ), + skins = aSkinList(), ), onSelect = {}, ) } + +internal fun aSkinList() = persistentListOf( + EmojiSkin("1F44D-1F3FB", "thumbs up: light skin tone", "👍🏻"), + EmojiSkin("1F44D-1F3FC", "thumbs up: medium-light skin tone", "👍🏼"), + EmojiSkin("1F44D-1F3FD", "thumbs up: medium skin tone", "👍🏽"), + EmojiSkin("1F44D-1F3FE", "thumbs up: medium-dark skin tone", "👍🏾"), + EmojiSkin("1F44D-1F3FF", "thumbs up: dark skin tone", "👍🏿"), +) From 3ec7aa2a551650826bfc11563a20bfeeaf7503fe Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Jul 2026 14:34:14 +0200 Subject: [PATCH 51/64] Remove default parameter values and make them not nullable. --- .../components/customreaction/EmojiItem.kt | 32 ++++++++++--------- .../customreaction/picker/EmojiPicker.kt | 8 ++--- .../customreaction/picker/SkinTonePicker.kt | 6 ++-- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index db5967e9a4..a191de7202 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -70,13 +70,13 @@ fun EmojiItem( item: Emoji, isSelected: Boolean, onSelectEmoji: (Emoji) -> Unit, + onLongPress: (Emoji) -> Unit, + onDismissSkinPicker: () -> Unit, + skinPickerEmoji: Emoji?, + selectedSkinUnicodes: ImmutableSet, + hasSelectedSkin: Boolean, modifier: Modifier = Modifier, emojiSize: TextUnit = 20.sp, - onLongPress: ((Emoji) -> Unit)? = null, - skinPickerEmoji: Emoji? = null, - onDismissSkinPicker: (() -> Unit)? = null, - selectedSkinUnicodes: ImmutableSet = persistentSetOf(), - hasSelectedSkin: Boolean = false, ) { val backgroundColor = when { isSelected -> ElementTheme.colors.bgActionPrimaryRest @@ -139,12 +139,10 @@ fun EmojiItem( var startOffset = Offset.Zero detectDragGesturesAfterLongPress( onDragStart = { position -> - if (onLongPress != null) { - startOffset = position - dismissed = false - onLongPress(item) - hoveredIndex = -1 - } + startOffset = position + dismissed = false + onLongPress(item) + hoveredIndex = -1 }, onDrag = { change, _ -> if (!dismissed) { @@ -159,7 +157,7 @@ fun EmojiItem( if (!isValidDrag) { dismissed = true hoveredIndex = -1 - onDismissSkinPicker?.invoke() + onDismissSkinPicker() } else { val skinItemsCount = item.skins!!.size // Original + variants @@ -199,7 +197,7 @@ fun EmojiItem( } dismissed = false hoveredIndex = -1 - onDismissSkinPicker?.invoke() + onDismissSkinPicker() }, onDragCancel = { dismissed = false @@ -241,7 +239,7 @@ fun EmojiItem( val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize val popupOffsetPx = with(density) { -pickerHeight.roundToPx() } Popup( - onDismissRequest = { onDismissSkinPicker?.invoke() }, + onDismissRequest = { onDismissSkinPicker() }, alignment = Alignment.BottomCenter, offset = IntOffset(0, popupOffsetPx), ) { @@ -249,7 +247,7 @@ fun EmojiItem( emoji = item, onSelect = { selectedEmoji -> onSelectEmoji(selectedEmoji) - onDismissSkinPicker?.invoke() + onDismissSkinPicker() }, hoveredIndex = hoveredIndex, selectedUnicodes = selectedSkinUnicodes, @@ -282,6 +280,10 @@ internal fun EmojiItemPreview() = ElementPreview { isSelected = isSelected, onSelectEmoji = {}, hasSelectedSkin = hasSelectedSkin, + onLongPress = {}, + onDismissSkinPicker = {}, + skinPickerEmoji = null, + selectedSkinUnicodes = persistentSetOf(), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt index 05fb849ce9..ce65a435f4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt @@ -131,10 +131,10 @@ private fun EmojiResults( emojis: ImmutableList, isEmojiSelected: (Emoji) -> Boolean, onSelectEmoji: (Emoji) -> Unit, - onLongPress: ((Emoji) -> Unit)? = null, - skinPickerEmoji: Emoji? = null, - onDismissSkinPicker: (() -> Unit)? = null, - selectedEmojis: ImmutableSet = persistentSetOf(), + onLongPress: (Emoji) -> Unit, + skinPickerEmoji: Emoji?, + onDismissSkinPicker: () -> Unit, + selectedEmojis: ImmutableSet, ) { LazyVerticalGrid( modifier = Modifier.fillMaxSize(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt index 56540e8251..1ac66b5378 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt @@ -43,10 +43,10 @@ val SkinTonePadding = 4.dp @Composable fun SkinTonePicker( emoji: Emoji, + hoveredIndex: Int, + selectedUnicodes: ImmutableSet, onSelect: (Emoji) -> Unit, modifier: Modifier = Modifier, - hoveredIndex: Int = -1, - selectedUnicodes: ImmutableSet = persistentSetOf(), ) { val skins = emoji.skins.orEmpty() val emojiSize = 32.dp.toSp() @@ -122,6 +122,8 @@ internal fun SkinTonePickerPreview() = ElementPreview { unicode = "👍", skins = aSkinList(), ), + hoveredIndex = -1, + selectedUnicodes = persistentSetOf(), onSelect = {}, ) } From 243acf1ee05f9711484c53c1bc1e16397c85784b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Jul 2026 15:19:18 +0200 Subject: [PATCH 52/64] Add more screenshots --- .../components/customreaction/EmojiItem.kt | 29 +++++++++++++++++++ .../tests/konsist/KonsistPreviewTest.kt | 1 + 2 files changed, 30 insertions(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index a191de7202..db3a70715d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -290,3 +290,32 @@ internal fun EmojiItemPreview() = ElementPreview { } } } + +@PreviewsDayNight +@Composable +internal fun EmojiItemWithPopupPreview() = ElementPreview { + Row( + modifier = Modifier + .padding(top = 100.dp, bottom = 4.dp, start = 200.dp, end = 200.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val item = Emoji( + hexcode = "", + label = "", + tags = null, + shortcodes = persistentListOf(), + unicode = "👍", + skins = aSkinList(), + ) + EmojiItem( + item = item, + isSelected = false, + onSelectEmoji = {}, + hasSelectedSkin = false, + onLongPress = {}, + onDismissSkinPicker = {}, + skinPickerEmoji = item, + selectedSkinUnicodes = persistentSetOf("👍🏾"), + ) + } +} diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 433237cd13..ddd1082ace 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -87,6 +87,7 @@ class KonsistPreviewTest { "BackgroundVerticalGradientDisabledPreview", "BackgroundVerticalGradientPreview", "ColorAliasesPreview", + "EmojiItemWithPopupPreview", "FocusedEventPreview", "GradientFloatingActionButtonCircleShapePreview", "HeaderFooterPageScrollablePreview", From 4190939ff8d624b82161aa535387057035f935e9 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 7 Jul 2026 14:54:55 +0000 Subject: [PATCH 53/64] Update screenshots --- ....components.customreaction_EmojiItemWithPopup_Day_0_en.png | 3 +++ ...omponents.customreaction_EmojiItemWithPopup_Night_0_en.png | 3 +++ ....timeline.components.customreaction_EmojiItem_Day_0_en.png | 4 ++-- ...imeline.components.customreaction_EmojiItem_Night_0_en.png | 4 ++-- 4 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Day_0_en.png new file mode 100644 index 0000000000..912b90b537 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4746d11c30648f7c517b0880021e5a1dc6ed2819af6c800b081b0d842ac2c384 +size 5325 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Night_0_en.png new file mode 100644 index 0000000000..45011fea4d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eede6f990a7a1dbddee1cd9e722b30600160d475ad2ae72faebcb773f52aaa7b +size 20793 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItem_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItem_Day_0_en.png index 9ccf324c94..3971eb1c25 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItem_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItem_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f11b581b1b7b5b0098390067f1f227537a0d30687002e429937797e3cad57392 -size 7457 +oid sha256:d5d9248a5372da8d93013991d8487946f5a133987f75ae1aeccba9346b05d9ee +size 15280 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItem_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItem_Night_0_en.png index 1a1e717c8c..de8d641791 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItem_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItem_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c90af9a0afef94b7a960f5fc6b399412b05d6814d5da9ac920ff191f29ed05e3 -size 7477 +oid sha256:b415e40a4169a1b4d321daba926b3f90871109ecf49db1e1be9ed929580c21c7 +size 14963 From 4fd0be1fe5020ade909ecdea666058999509c697 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:46:42 +0000 Subject: [PATCH 54/64] Review --- .../mediaupload/impl/AndroidMediaPreProcessor.kt | 6 +++--- .../mediaupload/impl/SvgDimensionExtractor.kt | 15 +++++++-------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 406f4e89bf..b374e530f4 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -223,10 +223,10 @@ class AndroidMediaPreProcessor( private suspend fun processSvgImage(uri: Uri, mimeType: String): MediaUploadInfo { Timber.d("Processing SVG image ${uri.path.orEmpty().hash()}") val file = copyToTmpFile(uri) - val (width, height) = svgDimensionExtractor.extractDimensions(file) + val size = svgDimensionExtractor.extractDimensions(file) val imageInfo = ImageInfo( - width = width, - height = height, + width = size.width.toLong(), + height = size.height.toLong(), mimetype = mimeType, size = file.length(), thumbnailInfo = null, diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt index 5ef756a904..f32b596df1 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. + * 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. @@ -16,7 +15,7 @@ class SvgDimensionExtractor( private val defaultWidth: Long = 640L, private val defaultHeight: Long = 480L, ) { - fun extractDimensions(file: File): Pair { + fun extractDimensions(file: File): android.util.Size { return file.inputStream().use { inputStream -> try { val parser = Xml.newPullParser() @@ -32,7 +31,7 @@ class SvgDimensionExtractor( val parsedHeight = height?.let { parseLength(it) } if (parsedWidth != null && parsedHeight != null) { - return parsedWidth to parsedHeight + return android.util.Size(parsedWidth.toInt(), parsedHeight.toInt()) } if (viewBox != null) { @@ -40,17 +39,17 @@ class SvgDimensionExtractor( if (parts.size == 4 && parts[2] != null && parts[3] != null) { val vbWidth = parts[2]!!.toLong().coerceAtLeast(1) val vbHeight = parts[3]!!.toLong().coerceAtLeast(1) - return vbWidth to vbHeight + return android.util.Size(vbWidth.toInt(), vbHeight.toInt()) } } - return (parsedWidth ?: defaultWidth) to (parsedHeight ?: defaultHeight) + return android.util.Size((parsedWidth ?: defaultWidth).toInt(), (parsedHeight ?: defaultHeight).toInt()) } eventType = parser.next() } - defaultWidth to defaultHeight + android.util.Size(defaultWidth.toInt(), defaultHeight.toInt()) } catch (_: Exception) { - defaultWidth to defaultHeight + android.util.Size(defaultWidth.toInt(), defaultHeight.toInt()) } } } From 885e042c12f5210b66a27e3edef1a681a2862710 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 8 Jul 2026 09:50:22 +0200 Subject: [PATCH 55/64] Initial content scanner integration (#7139) * Create a `ContentScanner` component: - Add `:features:contentscanner:api` module with the interface. - Implement the concrete component using the Rust SDK in `:libraries:matrix:impl`. This can be used to both set up the content scanner media fetching in the SDK Client and manually scanning media sources from the Android side * Create `ContentScannerUrlProvider` in a new `:features:contentscaner:api` module as a noop implementation Use it as a placeholder for the code to instantiate a `RustContentScanner` with the fetched URL and set it as part of the SDK client. * Expose the optional `ContentScanner` in the `@SessionScope` * Split the storage part of the Element well-known retriever so it can be reused for the non-session bound retriever. Also add the new `content_scanner_url` field to it. This will be used by the enterprise code to fetch the actual content scanner url. * Bump enterprise commit to have access to the actual content scanner url provider implementation * Fix tests and lint issues * Use the right namespace for `:features:contentscanner:api`, also use the right namespace for `impl` --------- Co-authored-by: Benoit Marty --- enterprise | 2 +- features/contentscanner/api/build.gradle.kts | 23 ++ features/contentscanner/impl/build.gradle.kts | 35 +++ .../libraries/matrix/api/MatrixClient.kt | 2 + .../matrix/api/scanner/ContentScanner.kt | 27 ++ .../api/scanner/ContentScannerUrlProvider.kt | 18 ++ .../libraries/matrix/impl/RustMatrixClient.kt | 2 + .../matrix/impl/RustMatrixClientFactory.kt | 23 ++ .../matrix/impl/di/SessionMatrixModule.kt | 6 + .../DefaultContentScannerUrlProvider.kt | 20 ++ .../matrix/impl/scanner/RustContentScanner.kt | 42 +++ .../impl/RustMatrixClientFactoryTest.kt | 3 + .../matrix/impl/RustMatrixClientTest.kt | 2 + .../impl/scanner/FakeFfiContentScanner.kt | 24 ++ .../impl/scanner/RustContentScannerTest.kt | 56 ++++ .../libraries/matrix/test/FakeMatrixClient.kt | 2 + .../matrix/test/scanner/FakeContentScanner.kt | 19 ++ .../libraries/network/NetworkModule.kt | 4 + .../libraries/network/RetrofitFactory.kt | 6 +- .../wellknown/api/ElementWellKnown.kt | 1 + .../wellknown/api/ElementWellknownStore.kt | 14 + .../wellknown/api/WellknownRetrieverResult.kt | 6 + .../impl/DefaultSessionWellknownRetriever.kt | 70 +++-- .../wellknown/impl/DefaultWellKnownStore.kt | 63 ++++ .../impl/DefaultWellknownRetriever.kt | 12 +- .../impl/InternalElementWellKnown.kt | 2 + .../libraries/wellknown/impl/Mapper.kt | 3 +- .../DefaultSessionWellknownRetrieverTest.kt | 68 ++-- .../impl/DefaultWellKnownStoreTest.kt | 158 ++++++++++ .../impl/DefaultWellknownRetrieverTest.kt | 291 ++++++++++++++++++ libraries/wellknown/test/build.gradle.kts | 3 + .../test/FakeElementWellknownStore.kt | 53 ++++ .../features/wellknown/test/Fixtures.kt | 2 + 33 files changed, 988 insertions(+), 74 deletions(-) create mode 100644 features/contentscanner/api/build.gradle.kts create mode 100644 features/contentscanner/impl/build.gradle.kts create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScanner.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/FakeFfiContentScanner.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScannerTest.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/scanner/FakeContentScanner.kt create mode 100644 libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt create mode 100644 libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt create mode 100644 libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt create mode 100644 libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt create mode 100644 libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt diff --git a/enterprise b/enterprise index efdf9d9a15..bc38a11f95 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit efdf9d9a15a5b600c55f674d64794e7737ad906e +Subproject commit bc38a11f95988cc1bbc209decb998f21c465ded0 diff --git a/features/contentscanner/api/build.gradle.kts b/features/contentscanner/api/build.gradle.kts new file mode 100644 index 0000000000..0e3d592878 --- /dev/null +++ b/features/contentscanner/api/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.contentscanner.api" +} + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/contentscanner/impl/build.gradle.kts b/features/contentscanner/impl/build.gradle.kts new file mode 100644 index 0000000000..53e8843b1d --- /dev/null +++ b/features/contentscanner/impl/build.gradle.kts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +import extension.setupDependencyInjection + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.contentscanner.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.features.contentscanner.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 6546e4c41a..8f9c6601ca 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncService @@ -71,6 +72,7 @@ interface MatrixClient { val ignoredUsersFlow: StateFlow> val roomMembershipObserver: RoomMembershipObserver val ownBeaconInfoUpdates: Flow + val contentScanner: ContentScanner? suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? suspend fun getRoom(roomId: RoomId): BaseRoom? suspend fun findDM(userId: UserId): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScanner.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScanner.kt new file mode 100644 index 0000000000..e2c4839c4d --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScanner.kt @@ -0,0 +1,27 @@ +/* + * 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.scanner + +import io.element.android.libraries.matrix.api.media.MediaSource + +/** + * Component used to manually scan media content for potential security risks. + * + * While the Matrix SDK automatically scans media content we load if a content scanner instance is provided, this interface allows for scanning + * some media sources in advance without actually loading their contents, which can be useful for pre-fetching scenarios. + */ +interface ContentScanner { + /** + * Manually scans the given [mediaSource] for potential security risks. + * + * @param mediaSource The media source to scan. + * @return A [Result] containing a [Boolean] indicating whether the content is safe (true) or potentially unsafe (false). + * If the scan fails, the [Result] will contain an exception. + */ + suspend fun scan(mediaSource: MediaSource): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt new file mode 100644 index 0000000000..600866f586 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt @@ -0,0 +1,18 @@ +/* + * 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.scanner + +/** + * Provides the URL of the content scanner service for a given homeserver, if any is set up. + */ +fun interface ContentScannerUrlProvider { + /** + * Returns the URL of the content scanner service for the given [homeserver], or `null` if no content scanner is set up. + */ + suspend fun getContentScannerUrl(homeserver: String): Result +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 2cfbbbdc73..5c81a3f085 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncState @@ -150,6 +151,7 @@ class RustMatrixClient( private val featureFlagService: FeatureFlagService, private val analyticsService: AnalyticsService, private val workManagerScheduler: WorkManagerScheduler, + override val contentScanner: ContentScanner?, ) : MatrixClient { override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index fae99b75c8..b4a9272166 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -17,11 +17,14 @@ import io.element.android.libraries.di.CacheDirectory import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.paths.SessionPaths +import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.impl.analytics.UtdTracker import io.element.android.libraries.matrix.impl.paths.getSessionPaths import io.element.android.libraries.matrix.impl.proxy.ProxyProvider import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory +import io.element.android.libraries.matrix.impl.scanner.RustContentScanner import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider import io.element.android.libraries.matrix.impl.util.anonymizedTokens import io.element.android.libraries.network.useragent.UserAgentProvider @@ -34,6 +37,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.ContentScanner import org.matrix.rustcomponents.sdk.CrossProcessLockConfig import org.matrix.rustcomponents.sdk.RequestConfig import org.matrix.rustcomponents.sdk.Session @@ -66,6 +70,7 @@ class RustMatrixClientFactory( private val clientBuilderProvider: ClientBuilderProvider, private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider, private val workManagerScheduler: WorkManagerScheduler, + private val contentScannerUrlProvider: ContentScannerUrlProvider, ) { private val sessionDelegate = RustClientSessionDelegate( sessionStore = sessionStore, @@ -113,6 +118,23 @@ class RustMatrixClientFactory( client.setUtdDelegate(UtdTracker(analyticsService)) + val domainName = UserId(client.userId()).domainName + // If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client. + // This allows the SDK to use the content scanner for automatic media scanning. + // If no content scanner URL is available, the contentScanner will be null. + val contentScanner = domainName?.let { + contentScannerUrlProvider.getContentScannerUrl(domainName) + .getOrNull() + ?.let { contentScannerUrl -> + val contentScanner = ContentScanner(contentScannerUrl) + client.setContentScanner(contentScanner) + RustContentScanner( + client = client, + rustScanner = contentScanner, + ) + } + } + val syncService = client.syncService() .withSharePos(true) .withOfflineMode() @@ -132,6 +154,7 @@ class RustMatrixClientFactory( featureFlagService = featureFlagService, analyticsService = analyticsService, workManagerScheduler = workManagerScheduler, + contentScanner = contentScanner, ).also { Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index bef24003b9..d4f49af416 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.verification.SessionVerificationService @@ -96,4 +97,9 @@ object SessionMatrixModule { fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider { return matrixClient.homeserverCapabilities() } + + @Provides + fun providesContentScanner(matrixClient: MatrixClient): ContentScanner? { + return matrixClient.contentScanner + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt new file mode 100644 index 0000000000..b43dcfb3e8 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt @@ -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.impl.scanner + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider + +/** + * Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL. + */ +@ContributesBinding(AppScope::class) +class DefaultContentScannerUrlProvider : ContentScannerUrlProvider { + override suspend fun getContentScannerUrl(homeserver: String): Result = Result.success(null) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt new file mode 100644 index 0000000000..ecfa4f0bcd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt @@ -0,0 +1,42 @@ +/* + * 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.scanner + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.scanner.ContentScanner +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.ContentScanner as RustScanner +import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource + +class RustContentScanner( + private val client: Client, + private val rustScanner: RustScanner, +) : ContentScanner { + override suspend fun scan(mediaSource: MediaSource): Result { + return runCatchingExceptions { + rustScanner.scan(client, mediaSource.toRustMediaSource()).clean + } + } +} + +private fun MediaSource.toRustMediaSource(): RustMediaSource { + val json = this.json + return try { + if (json != null) { + RustMediaSource.fromJson(json) + } else { + RustMediaSource.fromUrl(safeUrl) + } + } catch (e: LinkageError) { + // Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded + val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess + if (isTest) RustMediaSource(NoHandle) else throw e + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 1c25acf088..d1536d949e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider @@ -51,6 +52,7 @@ fun TestScope.createRustMatrixClientFactory( ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + contentScannerUrlProvider: ContentScannerUrlProvider = { Result.success(null) }, ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, appCoroutineScope = backgroundScope, @@ -65,4 +67,5 @@ fun TestScope.createRustMatrixClientFactory( clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), workManagerScheduler = workManagerScheduler, + contentScannerUrlProvider = contentScannerUrlProvider, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index a8e812d031..745fbf02d5 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.scanner.FakeContentScanner import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData @@ -156,5 +157,6 @@ class RustMatrixClientTest { featureFlagService = FakeFeatureFlagService(), analyticsService = FakeAnalyticsService(), workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), + contentScanner = FakeContentScanner(), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/FakeFfiContentScanner.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/FakeFfiContentScanner.kt new file mode 100644 index 0000000000..a359747198 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/FakeFfiContentScanner.kt @@ -0,0 +1,24 @@ +/* + * 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.scanner + +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ContentScanner +import org.matrix.rustcomponents.sdk.MediaSource +import org.matrix.rustcomponents.sdk.NoHandle +import uniffi.matrix_sdk_contentscanner.MediaScanResponse + +class FakeFfiContentScanner( + private val scan: suspend (client: Client, mediaSource: MediaSource) -> MediaScanResponse = { _, _ -> + MediaScanResponse(clean = true, info = "Just peachy") + }, +) : ContentScanner(NoHandle) { + override suspend fun scan(client: Client, mediaSource: MediaSource): MediaScanResponse { + return scan.invoke(client, mediaSource) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScannerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScannerTest.kt new file mode 100644 index 0000000000..8a1522c2f2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScannerTest.kt @@ -0,0 +1,56 @@ +/* + * 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.scanner + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.test.media.aMediaSource +import kotlinx.coroutines.test.runTest +import org.junit.Test +import uniffi.matrix_sdk_contentscanner.MediaScanResponse + +class RustContentScannerTest { + @Test + fun `test successful valid scan`() = runTest { + val scanner = RustContentScanner( + client = FakeFfiClient(), + rustScanner = FakeFfiContentScanner(), + ) + + scanner.scan(aMediaSource()).run { + assertThat(isSuccess).isTrue() + assertThat(getOrNull()).isTrue() + } + } + + @Test + fun `test successful invalid scan`() = runTest { + val scanner = RustContentScanner( + client = FakeFfiClient(), + rustScanner = FakeFfiContentScanner(scan = { _, _ -> MediaScanResponse(clean = false, info = "Not clean") }) + ) + + scanner.scan(aMediaSource()).run { + assertThat(isSuccess).isTrue() + assertThat(getOrNull()).isFalse() + } + } + + @Test + fun `test failed scan`() = runTest { + val scanner = RustContentScanner( + client = FakeFfiClient(), + rustScanner = FakeFfiContentScanner(scan = { _, _ -> throw IllegalStateException("BOOM") }) + ) + + scanner.scan(aMediaSource()).run { + assertThat(isFailure).isTrue() + assertThat(getOrNull()).isNull() + } + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 2397f12443..98fe01553c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncService @@ -122,6 +123,7 @@ class FakeMatrixClient( private val getMapStyleUrlResult: () -> Result = { lambdaError() }, private val getDatabaseSizesLambda: () -> Result = { lambdaError() }, private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, + override val contentScanner: ContentScanner? = null ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/scanner/FakeContentScanner.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/scanner/FakeContentScanner.kt new file mode 100644 index 0000000000..a4d9ce72e7 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/scanner/FakeContentScanner.kt @@ -0,0 +1,19 @@ +/* + * 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.scanner + +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.scanner.ContentScanner + +class FakeContentScanner( + private val scan: suspend (mediaSource: MediaSource) -> Result = { Result.success(true) }, +) : ContentScanner { + override suspend fun scan(mediaSource: MediaSource): Result { + return scan.invoke(mediaSource) + } +} diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt index d8809e042c..90e78d860e 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt @@ -16,6 +16,7 @@ import dev.zacsweers.metro.SingleIn import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger import io.element.android.libraries.network.interceptors.UserAgentInterceptor +import okhttp3.Call import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import java.util.concurrent.TimeUnit @@ -36,6 +37,9 @@ object NetworkModule { addInterceptor(dynamicHttpLoggingInterceptor) }.build() + @Provides + fun providesCallFactory(okHttpClient: OkHttpClient): Call.Factory = Call.Factory { request -> okHttpClient.newCall(request) } + @Provides @SingleIn(AppScope::class) fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor { diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt index 39a54dca61..ecb1004b90 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt @@ -11,19 +11,19 @@ package io.element.android.libraries.network import dev.zacsweers.metro.Inject import io.element.android.libraries.androidutils.json.JsonProvider import io.element.android.libraries.core.uri.ensureTrailingSlash +import okhttp3.Call import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory @Inject class RetrofitFactory( - private val okHttpClient: () -> OkHttpClient, + private val callFactory: () -> Call.Factory, private val json: () -> JsonProvider, ) { fun create(baseUrl: String): Retrofit = Retrofit.Builder() .baseUrl(baseUrl.ensureTrailingSlash()) .addConverterFactory(json()().asConverterFactory("application/json".toMediaType())) - .callFactory { request -> okHttpClient().newCall(request) } + .callFactory(callFactory()) .build() } diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt index 8f2c07ab68..45448f66c2 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt @@ -16,4 +16,5 @@ data class ElementWellKnown( val notificationSound: String?, val identityProviderAppScheme: String?, val customRecoveryPassphrase: CustomRecoveryPassphrase?, + val contentScannerUrl: String?, ) diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt new file mode 100644 index 0000000000..80b2ab4c5e --- /dev/null +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt @@ -0,0 +1,14 @@ +/* + * 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.wellknown.api + +interface ElementWellknownStore { + suspend fun get(domain: String): WellknownRetrieverResult + suspend fun update(domain: String, wellknown: String): Result + suspend fun delete(domain: String): Result +} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt index 3236f18660..fcc54df0b5 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt @@ -14,6 +14,11 @@ sealed interface WellknownRetrieverResult { */ data class Success(val data: T) : WellknownRetrieverResult + /** + * Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched. + */ + data class Outdated(val data: T) : WellknownRetrieverResult + /** * Well-known data is not found (file does not exist server side, we got a 404). */ @@ -26,6 +31,7 @@ sealed interface WellknownRetrieverResult { fun dataOrNull(): T? = when (this) { is Success -> data + is Outdated -> data is Error -> null NotFound -> null } diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt index a0223e93cc..3446cdcb65 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt @@ -10,17 +10,15 @@ package io.element.android.libraries.wellknown.impl import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.androidutils.json.JsonProvider -import io.element.android.libraries.cachestore.api.CacheData -import io.element.android.libraries.cachestore.api.CacheStore import io.element.android.libraries.core.extensions.mapCatchingExceptions import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.wellknown.api.ElementWellKnown +import io.element.android.libraries.wellknown.api.ElementWellknownStore import io.element.android.libraries.wellknown.api.SessionWellknownRetriever import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber @@ -29,34 +27,41 @@ import timber.log.Timber class DefaultSessionWellknownRetriever( private val matrixClient: MatrixClient, private val json: JsonProvider, - private val cacheStore: CacheStore, - private val systemClock: SystemClock, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val elementWellknownStore: ElementWellknownStore, ) : SessionWellknownRetriever { private val domain by lazy { matrixClient.userIdServerName() } override suspend fun getElementWellKnown(): WellknownRetrieverResult { - val url = "https://$domain/.well-known/element/element.json" - val cacheData = cacheStore.getData(url) - if (cacheData != null) { - Timber.d("Element .well-known data retrieved from cache for $domain") - // If the cache is outdated, trigger a refresh in background but still return the cached value - if (systemClock.epochMillis() > cacheData.updatedAt + CACHE_VALIDITY_MILLIS) { + val cacheData = elementWellknownStore.get(domain) + return when (cacheData) { + is WellknownRetrieverResult.Success -> { + Timber.d("Using cached well-known for domain $domain") + cacheData + } + is WellknownRetrieverResult.Outdated -> { + // Return the outdated data but refresh in the background + // If the cache is missing or outdated, trigger a refresh in background but still return the cached value + Timber.d("Outdated cached well-known for domain $domain, returning existing value and fetching new one from network") sessionCoroutineScope.launch { + val url = "https://$domain/.well-known/element/element.json" fetchElementWellKnown(url) } + cacheData } - try { - val parsed = json().decodeFromString(cacheData.value).map() - return WellknownRetrieverResult.Success(parsed) - } catch (e: Exception) { - Timber.e(e, "Failed to parse cached Element .well-known data for $domain, deleting cache") - cacheStore.deleteData(url) + is WellknownRetrieverResult.NotFound -> { + // Try to fetch from the server + Timber.d("No cached well-known for domain $domain, fetching from network") + val url = "https://$domain/.well-known/element/element.json" + fetchElementWellKnown(url) + } + is WellknownRetrieverResult.Error -> { + // Return the error + Timber.e(cacheData.exception, "Error retrieving well-known for domain $domain") + cacheData.exception.toWellknownRetrieverResult() } } - - return fetchElementWellKnown(url) } private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult { @@ -66,13 +71,11 @@ class DefaultSessionWellknownRetriever( val data = String(it) val parsed = json().decodeFromString(data).map() // Also store in cache, if valid - cacheStore.storeData( - key = url, - data = CacheData( - value = data, - updatedAt = systemClock.epochMillis(), - ) - ) + elementWellknownStore.update(domain, data) + .onFailure { exception -> + Timber.e(exception, "Failed to parse cached Element .well-known data for $domain, deleting cache") + elementWellknownStore.delete(domain) + } parsed } .toWellknownRetrieverResult() @@ -85,16 +88,15 @@ class DefaultSessionWellknownRetriever( onFailure = { Timber.e(it, "Failed to retrieve Element .well-known from $domain") // This check on message value is not ideal but this is what we got from the SDK. - if ((it as? ClientException.Generic)?.message?.contains("404") == true) { - WellknownRetrieverResult.NotFound - } else { - WellknownRetrieverResult.Error(it as Exception) - } + it.toWellknownRetrieverResult() } ) - companion object { - // 1 day - private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L + private fun Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult { + return if ((this as? ClientException.Generic)?.message?.contains("404") == true) { + WellknownRetrieverResult.NotFound + } else { + WellknownRetrieverResult.Error(this as Exception) + } } } diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt new file mode 100644 index 0000000000..c3cfcc5019 --- /dev/null +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt @@ -0,0 +1,63 @@ +/* + * 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.wellknown.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.cachestore.api.CacheData +import io.element.android.libraries.cachestore.api.CacheStore +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.wellknown.api.ElementWellKnown +import io.element.android.libraries.wellknown.api.ElementWellknownStore +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult +import io.element.android.services.toolbox.api.systemclock.SystemClock + +@ContributesBinding(AppScope::class) +class DefaultWellKnownStore( + private val cacheStore: CacheStore, + private val json: JsonProvider, + private val systemClock: SystemClock, +) : ElementWellknownStore { + override suspend fun get(domain: String): WellknownRetrieverResult { + return runCatchingExceptions { + val cachedData = cacheStore.getData(key(domain)) + if (cachedData != null) { + val data = json().decodeFromString(cachedData.value).map() + if (systemClock.epochMillis() > cachedData.updatedAt + CACHE_VALIDITY_MILLIS) { + WellknownRetrieverResult.Outdated(data) + } else { + WellknownRetrieverResult.Success(data) + } + } else { + WellknownRetrieverResult.NotFound + } + }.recover { + WellknownRetrieverResult.Error(it as Exception) + }.getOrThrow() + } + + override suspend fun update(domain: String, wellknown: String): Result { + return runCatchingExceptions { + cacheStore.storeData(key(domain), CacheData(wellknown, systemClock.epochMillis())) + } + } + + override suspend fun delete(domain: String): Result { + return runCatchingExceptions { + cacheStore.deleteData(key(domain)) + } + } + + private fun key(domain: String): String = "https://$domain/.well-known/element/element.json" + + companion object { + // 1 day + private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L + } +} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt index 02dae72a43..a43584d542 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt @@ -10,26 +10,34 @@ package io.element.android.libraries.wellknown.impl import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.json.JsonProvider import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.network.RetrofitFactory import io.element.android.libraries.wellknown.api.ElementWellKnown +import io.element.android.libraries.wellknown.api.ElementWellknownStore import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import retrofit2.HttpException import timber.log.Timber import java.net.HttpURLConnection +import java.net.URL @ContributesBinding(AppScope::class) class DefaultWellknownRetriever( private val retrofitFactory: RetrofitFactory, + private val elementWellknownStore: ElementWellknownStore, + private val jsonProvider: JsonProvider, ) : WellknownRetriever { override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult { + val domain = URL(baseUrl).host return buildWellknownApi(baseUrl) .map { wellknownApi -> try { - val result = wellknownApi.getElementWellKnown().map() - WellknownRetrieverResult.Success(result) + val json = jsonProvider() + val result = wellknownApi.getElementWellKnown() + elementWellknownStore.update(domain, json.encodeToString(result)) + WellknownRetrieverResult.Success(result.map()) } catch (e: Exception) { // Is it a 404? Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl") diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt index cd0f39c77f..ad60caefa5 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt @@ -44,6 +44,8 @@ data class InternalElementWellKnown( val identityProviderAppScheme: String? = null, @SerialName("custom_recovery_passphrase") val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null, + @SerialName("content_scanner_url") + val contentScannerUrl: String? = null, ) @Serializable diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt index c8760881d4..420dea2bae 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt @@ -22,7 +22,7 @@ private val loggerTag = LoggerTag("Wellknown") */ private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1 -internal fun InternalElementWellKnown.map() = ElementWellKnown( +fun InternalElementWellKnown.map() = ElementWellKnown( registrationHelperUrl = registrationHelperUrl, enforceElementPro = enforceElementPro, rageshakeUrl = rageshakeUrl, @@ -30,6 +30,7 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown( notificationSound = notificationSound, identityProviderAppScheme = identityProviderAppScheme, customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(), + contentScannerUrl = contentScannerUrl, ) private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase { diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt index a64d156f6e..ec939b9988 100644 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt @@ -11,20 +11,16 @@ package io.element.android.libraries.wellknown.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.wellknown.test.FakeElementWellknownStore import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.androidutils.json.DefaultJsonProvider import io.element.android.libraries.androidutils.json.JsonProvider -import io.element.android.libraries.cachestore.api.CacheData -import io.element.android.libraries.cachestore.api.CacheStore +import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.services.toolbox.api.systemclock.SystemClock -import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP -import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -53,6 +49,7 @@ class DefaultSessionWellknownRetrieverTest { notificationSound = null, identityProviderAppScheme = null, customRecoveryPassphrase = null, + contentScannerUrl = null, ) ) ) @@ -79,6 +76,7 @@ class DefaultSessionWellknownRetrieverTest { notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", customRecoveryPassphrase = null, + contentScannerUrl = "https://content-scanner.example.com", ) ) ) @@ -108,6 +106,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = null, notificationSound = null, identityProviderAppScheme = null, + contentScannerUrl = null, customRecoveryPassphrase = null, ) ) @@ -225,16 +224,23 @@ class DefaultSessionWellknownRetrieverTest { assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java) } + @Test + fun `get element wellknown 404 http error counts as not found`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.failure(ClientException.Generic("Not Found: 404 status code", "The file could not be found")) + } + ) + assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.NotFound::class.java) + } + @Test fun `get element wellknown hitting cache`() = runTest { val sut = createDefaultSessionWellknownRetriever( getUrlLambda = { lambdaError() }, - cacheStore = InMemoryCacheStore( + cacheStore = FakeElementWellknownStore( initialData = mapOf( - WELLKNOWN_URL to CacheData( - value = WELLKNOWN_CONTENT, - updatedAt = A_FAKE_TIMESTAMP, - ) + WELLKNOWN_URL to WellknownRetrieverResult.Success(parsedWellKnownContent()) ) ) ) @@ -248,6 +254,7 @@ class DefaultSessionWellknownRetrieverTest { notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", customRecoveryPassphrase = null, + contentScannerUrl = "https://content-scanner.example.com", ) ) ) @@ -255,12 +262,9 @@ class DefaultSessionWellknownRetrieverTest { @Test fun `get element wellknown hitting cache containing invalid json`() = runTest { - val cacheStore = InMemoryCacheStore( + val cacheStore = FakeElementWellknownStore( initialData = mapOf( - WELLKNOWN_URL to CacheData( - value = WELLKNOWN_CONTENT, - updatedAt = A_FAKE_TIMESTAMP, - ) + WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON")) ) ) val sut = createDefaultSessionWellknownRetriever( @@ -272,28 +276,24 @@ class DefaultSessionWellknownRetrieverTest { ) assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java) // Ensure that the cache is deleted after the failure to parse it - assertThat(cacheStore.dataMap).isEmpty() + assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound) } @Test fun `get element wellknown hitting outdated cache`() = runTest { + val cacheStore = FakeElementWellknownStore( + initialData = mapOf( + WELLKNOWN_URL to WellknownRetrieverResult.Outdated(parsedWellKnownContent()), + ) + ) val sut = createDefaultSessionWellknownRetriever( getUrlLambda = { Result.success("{}".toByteArray()) }, - cacheStore = InMemoryCacheStore( - initialData = mapOf( - WELLKNOWN_URL to CacheData( - value = WELLKNOWN_CONTENT, - updatedAt = 0L, - ) - ), - ), - // 3 days later, so the cache is outdated - systemClock = FakeSystemClock(3 * 24 * 60 * 60 * 1000L) + cacheStore = cacheStore, ) assertThat(sut.getElementWellKnown()).isEqualTo( - WellknownRetrieverResult.Success( + WellknownRetrieverResult.Outdated( ElementWellKnown( registrationHelperUrl = "a_registration_url", enforceElementPro = true, @@ -302,6 +302,7 @@ class DefaultSessionWellknownRetrieverTest { notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", customRecoveryPassphrase = null, + contentScannerUrl = "https://content-scanner.example.com", ) ) ) @@ -314,20 +315,20 @@ class DefaultSessionWellknownRetrieverTest { ) } + private fun parsedWellKnownContent() = DefaultJsonProvider().invoke().decodeFromString(WELLKNOWN_CONTENT).map() + private fun TestScope.createDefaultSessionWellknownRetriever( + cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(), getUrlLambda: (String) -> Result, jsonProvider: JsonProvider = DefaultJsonProvider(), - cacheStore: CacheStore = InMemoryCacheStore(), - systemClock: SystemClock = FakeSystemClock(), ) = DefaultSessionWellknownRetriever( matrixClient = FakeMatrixClient( userIdServerNameLambda = { "user.domain.org" }, getUrlLambda = getUrlLambda, ), json = jsonProvider, - cacheStore = cacheStore, - systemClock = systemClock, sessionCoroutineScope = backgroundScope, + elementWellknownStore = cacheStore, ) companion object { @@ -338,7 +339,8 @@ class DefaultSessionWellknownRetrieverTest { "rageshake_url": "a_rageshake_url", "brand_color": "#FF0000", "notification_sound": "a_notification_sound.flac", - "idp_app_scheme": "an_app_scheme" + "idp_app_scheme": "an_app_scheme", + "content_scanner_url": "https://content-scanner.example.com" }""" } } diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt new file mode 100644 index 0000000000..11ec521fed --- /dev/null +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt @@ -0,0 +1,158 @@ +/* + * 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.wellknown.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.wellknown.test.anElementWellKnown +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +import io.element.android.libraries.cachestore.api.CacheData +import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private const val A_DOMAIN = "matrix.example.com" +private const val A_CACHE_KEY = "https://$A_DOMAIN/.well-known/element/element.json" + +// 1 day in millis +private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L + +class DefaultWellKnownStoreTest { + private val jsonProvider = DefaultJsonProvider() + + @Test + fun `get returns NotFound when cache is empty`() = runTest { + val sut = createDefaultWellKnownStore() + assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound) + } + + @Test + fun `get returns Success when cache has fresh data`() = runTest { + val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com") + val jsonString = jsonProvider().encodeToString(wellKnown) + val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) + val cacheStore = InMemoryCacheStore( + initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP)) + ) + val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) + + val result = sut.get(A_DOMAIN) + + assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map())) + } + + @Test + fun `get returns Outdated when cached data has expired`() = runTest { + val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com") + val jsonString = jsonProvider().encodeToString(wellKnown) + // Store data at timestamp 0, but clock is past the validity window + val expiredTimestamp = 0L + val nowTimestamp = expiredTimestamp + CACHE_VALIDITY_MILLIS + 1 + val clock = FakeSystemClock(epochMillisResult = nowTimestamp) + val cacheStore = InMemoryCacheStore( + initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, expiredTimestamp)) + ) + val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) + + val result = sut.get(A_DOMAIN) + + assertThat(result).isEqualTo(WellknownRetrieverResult.Outdated(wellKnown.map())) + } + + @Test + fun `get returns Error when cached data is invalid JSON`() = runTest { + val cacheStore = InMemoryCacheStore( + initialData = mapOf(A_CACHE_KEY to CacheData("not valid json at all", A_FAKE_TIMESTAMP)) + ) + val sut = createDefaultWellKnownStore(cacheStore = cacheStore) + + val result = sut.get(A_DOMAIN) + + assertThat(result).isInstanceOf(WellknownRetrieverResult.Error::class.java) + } + + @Test + fun `update stores data and get returns Success`() = runTest { + val wellKnown = """ + { + "registration_helper_url": "https://element.io", + "enforce_element_pro": true, + "rageshake_url": "https://example.org/rageshake", + "brand_color": "#FF0000", + "notification_sound": "ring.flac", + "idp_app_scheme": "io.element.app", + "custom_recovery_passphrase": { + "min_character_count": 8 + } + } + """.trimIndent() + val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) + val cacheStore = InMemoryCacheStore() + val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) + + val updateResult = sut.update(A_DOMAIN, wellKnown) + assertThat(updateResult.isSuccess).isTrue() + + val result = sut.get(A_DOMAIN) + assertThat(result).isEqualTo(WellknownRetrieverResult.Success(anElementWellKnown( + registrationHelperUrl = "https://element.io", + enforceElementPro = true, + rageshakeUrl = "https://example.org/rageshake", + brandColor = "#FF0000", + notificationSound = "ring.flac", + identityProviderAppScheme = "io.element.app", + customRecoveryPassphrase = CustomRecoveryPassphrase(8), + ))) + } + + @Test + fun `delete removes data and get returns NotFound`() = runTest { + val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com") + val jsonString = jsonProvider().encodeToString(wellKnown) + val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) + val cacheStore = InMemoryCacheStore( + initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP)) + ) + val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) + + val deleteResult = sut.delete(A_DOMAIN) + assertThat(deleteResult.isSuccess).isTrue() + + assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound) + } + + @Test + fun `get returns data at the exact cache validity boundary as Success`() = runTest { + val wellKnown = InternalElementWellKnown(notificationSound = "ping.flac") + val jsonString = jsonProvider().encodeToString(wellKnown) + // Exactly at the boundary: epochMillis == updatedAt + CACHE_VALIDITY_MILLIS → not expired + val storedAt = 1000L + val nowAtBoundary = storedAt + CACHE_VALIDITY_MILLIS + val clock = FakeSystemClock(epochMillisResult = nowAtBoundary) + val cacheStore = InMemoryCacheStore( + initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, storedAt)) + ) + val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) + + val result = sut.get(A_DOMAIN) + + assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map())) + } + + private fun createDefaultWellKnownStore( + cacheStore: InMemoryCacheStore = InMemoryCacheStore(), + systemClock: FakeSystemClock = FakeSystemClock(), + ) = DefaultWellKnownStore( + cacheStore = cacheStore, + json = jsonProvider, + systemClock = systemClock, + ) +} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt new file mode 100644 index 0000000000..0e9288f658 --- /dev/null +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.wellknown.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.wellknown.test.FakeElementWellknownStore +import io.element.android.features.wellknown.test.anElementWellKnown +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.network.RetrofitFactory +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase +import io.element.android.libraries.wellknown.api.ElementWellKnown +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.IOException +import org.junit.Test +import java.net.PortUnreachableException + +class DefaultWellknownRetrieverTest { + @Test + fun `get empty element wellknown`() = runTest { + val mockCall = mockCall(defaultResponse(body = "{}")) + val sut = createDefaultWellknownRetriever( + callFactory = { mockCall }, + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo( + WellknownRetrieverResult.Success( + ElementWellKnown( + registrationHelperUrl = null, + enforceElementPro = null, + rageshakeUrl = null, + brandColor = null, + notificationSound = null, + identityProviderAppScheme = null, + customRecoveryPassphrase = null, + contentScannerUrl = null, + ) + ) + ) + verify(exactly = 1) { mockCall.enqueue(any()) } + } + + @Test + fun `get element wellknown with full content`() = runTest { + val sut = createDefaultWellknownRetriever( + callFactory = { mockCall(defaultResponse()) } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo( + WellknownRetrieverResult.Success( + ElementWellKnown( + registrationHelperUrl = "a_registration_url", + enforceElementPro = true, + rageshakeUrl = "a_rageshake_url", + brandColor = "#FF0000", + notificationSound = "a_notification_sound.flac", + identityProviderAppScheme = "an_app_scheme", + customRecoveryPassphrase = null, + contentScannerUrl = "https://content-scanner.example.com", + ) + ) + ) + } + + @Test + fun `get element wellknown with unknown key`() = runTest { + val sut = createDefaultWellknownRetriever( + callFactory = { + mockCall(defaultResponse(body = """{ + "registration_helper_url": "a_registration_url", + "enforce_element_pro": true, + "rageshake_url": "a_rageshake_url", + // Note the trailing comma, and the comment! + "other": true, + }""".trimIndent())) + } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo( + WellknownRetrieverResult.Success( + ElementWellKnown( + registrationHelperUrl = "a_registration_url", + enforceElementPro = true, + rageshakeUrl = "a_rageshake_url", + brandColor = null, + notificationSound = null, + identityProviderAppScheme = null, + contentScannerUrl = null, + customRecoveryPassphrase = null, + ) + ) + ) + } + + @Test + fun `get element wellknown with custom recovery passphrase settings`() = runTest { + val sut = createDefaultWellknownRetriever( + callFactory = { + mockCall(defaultResponse(body = """{ + "custom_recovery_passphrase": { + "min_character_count": 8 + } + }""".trimIndent())) + } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo( + WellknownRetrieverResult.Success( + anElementWellKnown( + customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 8) + ) + ) + ) + } + + @Test + fun `get element wellknown with custom recovery passphrase settings missing min character count floors to 1`() = runTest { + val sut = createDefaultWellknownRetriever( + callFactory = { + mockCall(defaultResponse(body = """{ + "custom_recovery_passphrase": {} + }""".trimIndent())) + } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo( + WellknownRetrieverResult.Success( + anElementWellKnown( + customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1) + ) + ) + ) + } + + @Test + fun `get element wellknown with zero min character count floors to 1`() = runTest { + val sut = createDefaultWellknownRetriever( + callFactory = { + mockCall( + defaultResponse( + body = """{ + "custom_recovery_passphrase": { + "min_character_count": 0 + } + }""".trimIndent() + ) + ) + } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo( + WellknownRetrieverResult.Success( + anElementWellKnown( + customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1) + ) + ) + ) + } + + @Test + fun `get element wellknown with negative min character count floors to 1`() = runTest { + val sut = createDefaultWellknownRetriever( + callFactory = { + mockCall( + defaultResponse( + body = """{ + "custom_recovery_passphrase": { + "min_character_count": -5 + } + }""".trimIndent() + ) + ) + } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo( + WellknownRetrieverResult.Success( + anElementWellKnown( + customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1) + ) + ) + ) + } + + @Test + fun `get element wellknown json error`() = runTest { + val sut = createDefaultWellknownRetriever( + callFactory = { mockCall(defaultResponse(status = 500)) } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java) + } + + @Test + fun `get element wellknown network error`() = runTest { + val sut = createDefaultWellknownRetriever( + callFactory = { mockCall(error = PortUnreachableException()) } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java) + } + + @Test + fun `get element wellknown 404 http error counts as not found`() = runTest { + val sut = createDefaultWellknownRetriever( + callFactory = { mockCall(defaultResponse(status = 404)) } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.NotFound::class.java) + } + + @Test + fun `get element wellknown hitting cache containing invalid json`() = runTest { + val cacheStore = FakeElementWellknownStore( + initialData = mapOf( + WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON")) + ) + ) + val sut = createDefaultWellknownRetriever( + callFactory = { mockCall(defaultResponse(body = "invalid json")) }, + cacheStore = cacheStore, + jsonProvider = JsonProvider { error("Failed to parse JSON") } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java) + // Ensure that the cache is deleted after the failure to parse it + assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound) + } + + private fun defaultResponse( + body: String = WELLKNOWN_CONTENT, + status: Int = 200, + ) = Response.Builder() + .code(status) + .message("OK") + .protocol(okhttp3.Protocol.HTTP_1_1) + .request(Request.Builder().url(WELLKNOWN_URL).build()) + .body(body.toByteArray().toResponseBody()) + .build() + + private fun createDefaultWellknownRetriever( + callFactory: Call.Factory = Call.Factory { _: Request -> mockCall(defaultResponse()) }, + cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(), + jsonProvider: JsonProvider = DefaultJsonProvider(), + ) = DefaultWellknownRetriever( + retrofitFactory = RetrofitFactory( + callFactory = { callFactory }, + json = { jsonProvider } + ), + jsonProvider = jsonProvider, + elementWellknownStore = cacheStore, + ) + + companion object { + private const val WELLKNOWN_URL = "https://user.domain.org/.well-known/element/element.json" + private const val WELLKNOWN_CONTENT = """{ + "registration_helper_url": "a_registration_url", + "enforce_element_pro": true, + "rageshake_url": "a_rageshake_url", + "brand_color": "#FF0000", + "notification_sound": "a_notification_sound.flac", + "idp_app_scheme": "an_app_scheme", + "content_scanner_url": "https://content-scanner.example.com" + }""" + } +} + +private fun mockCall( + response: Response? = null, + error: IOException? = null, +): Call = mockk { + if (error != null) { + every { enqueue(any()) } answers { + val callback = firstArg() + callback.onFailure(this@mockk, error) + } + } else if (response != null) { + every { enqueue(any()) } answers { + val callback = firstArg() + callback.onResponse(this@mockk, response) + } + } else { + error("Either response or error must be provided") + } +} diff --git a/libraries/wellknown/test/build.gradle.kts b/libraries/wellknown/test/build.gradle.kts index 0ce22348b3..8eb47ef5ca 100644 --- a/libraries/wellknown/test/build.gradle.kts +++ b/libraries/wellknown/test/build.gradle.kts @@ -15,6 +15,9 @@ android { } dependencies { + implementation(libs.serialization.json) + implementation(projects.libraries.androidutils) implementation(projects.libraries.wellknown.api) + implementation(projects.libraries.wellknown.impl) implementation(projects.tests.testutils) } diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt new file mode 100644 index 0000000000..c5e2d2ae5b --- /dev/null +++ b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt @@ -0,0 +1,53 @@ +/* + * 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.features.wellknown.test + +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.wellknown.api.ElementWellKnown +import io.element.android.libraries.wellknown.api.ElementWellknownStore +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult +import io.element.android.libraries.wellknown.impl.InternalElementWellKnown +import io.element.android.libraries.wellknown.impl.map +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.JsonDecodingException +import java.net.URL + +class FakeElementWellknownStore( + initialData: Map> = emptyMap(), + private val jsonProvider: JsonProvider = DefaultJsonProvider(), +) : ElementWellknownStore { + private val cachedData = mutableMapOf>() + + init { + initialData.forEach { (url, result) -> + cachedData[URL(url).host] = result + } + } + + override suspend fun get(domain: String): WellknownRetrieverResult { + return cachedData[domain] ?: WellknownRetrieverResult.NotFound + } + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun update(domain: String, wellknown: String): Result { + val json = jsonProvider() + val mapped = try { + json.decodeFromString(wellknown).map() + } catch (e: JsonDecodingException) { + return Result.failure(e) + } + cachedData[domain] = WellknownRetrieverResult.Success(mapped) + return Result.success(Unit) + } + + override suspend fun delete(domain: String): Result { + cachedData.remove(domain) + return Result.success(Unit) + } +} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt index 51b06f7226..8ec8874ad9 100644 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt +++ b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt @@ -19,6 +19,7 @@ fun anElementWellKnown( notificationSound: String? = null, identityProviderAppScheme: String? = null, customRecoveryPassphrase: CustomRecoveryPassphrase? = null, + contentScannerUrl: String? = null, ) = ElementWellKnown( registrationHelperUrl = registrationHelperUrl, enforceElementPro = enforceElementPro, @@ -27,6 +28,7 @@ fun anElementWellKnown( notificationSound = notificationSound, identityProviderAppScheme = identityProviderAppScheme, customRecoveryPassphrase = customRecoveryPassphrase, + contentScannerUrl = contentScannerUrl, ) fun aCustomRecoveryPassphrase( From 25479480c4f8040eca118ea93577250704f3fb18 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Jul 2026 09:55:08 +0200 Subject: [PATCH 56/64] Update enhancement template so that global feature request are opened on element-meta --- .github/ISSUE_TEMPLATE/enhancement.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml index 114bc2bc65..e43022edab 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.yml +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -1,11 +1,11 @@ name: Enhancement request -description: Do you have a suggestion or feature request? +description: Do you have a suggestion or feature request? Please open issue on the repo element-hq/element-meta the request is global to all Element clients. labels: [T-Enhancement] body: - type: markdown attributes: value: | - Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please [start a discussion here](https://github.com/element-hq/element-meta/discussions/new?category=ideas). + Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please open an issue on the [element-meta repository](https://github.com/element-hq/element-meta/issues/new?template=enhancement.yml) instead. - type: textarea id: usecase attributes: From 15149af6b7be0f469db65be8d1e97d81b2657507 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Jul 2026 09:59:16 +0200 Subject: [PATCH 57/64] Update bug template question as many users do not understand the question. Also change the default answer to `No`. --- .github/ISSUE_TEMPLATE/bug.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 73f8c96ebc..71370e3296 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -69,18 +69,18 @@ body: description: | Trigger the defect, then click on the menu from the room list then "Report a bug". Describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers. options: - - 'Yes' - 'No' + - 'Yes' validations: required: true - type: dropdown id: pr attributes: - label: Are you willing to provide a PR? + label: Will you submit a Pull Request? description: | - Providing a PR can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :). + Submitting a Pull Request can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :). options: - - 'Yes' - 'No' + - 'Yes' validations: required: true From e0161e5b250a8cb362e5b26b1cd37ce838f6b871 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Jul 2026 10:02:04 +0200 Subject: [PATCH 58/64] Fix typo --- .github/ISSUE_TEMPLATE/enhancement.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml index e43022edab..d2715e6e8b 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.yml +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -1,5 +1,5 @@ name: Enhancement request -description: Do you have a suggestion or feature request? Please open issue on the repo element-hq/element-meta the request is global to all Element clients. +description: Do you have a suggestion or feature request? Please open issue on the repo element-hq/element-meta if the request is global to all Element clients. labels: [T-Enhancement] body: - type: markdown From 28abf3049e5f25d1ae4433dbc6a9aae37fa94529 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Jul 2026 11:55:50 +0200 Subject: [PATCH 59/64] Cleanup AndroidManifest.xml files. --- app/src/main/AndroidManifest.xml | 3 --- features/call/impl/src/main/AndroidManifest.xml | 1 + libraries/androidutils/src/main/AndroidManifest.xml | 1 - .../api => mediaviewer/impl}/src/main/AndroidManifest.xml | 6 +++--- .../voicerecorder}/impl/src/main/AndroidManifest.xml | 0 5 files changed, 4 insertions(+), 7 deletions(-) rename libraries/{matrix/api => mediaviewer/impl}/src/main/AndroidManifest.xml (60%) rename {features/messages => libraries/voicerecorder}/impl/src/main/AndroidManifest.xml (100%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d63e18ec1a..f6a3225cb8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,9 +8,6 @@ - - - + diff --git a/libraries/androidutils/src/main/AndroidManifest.xml b/libraries/androidutils/src/main/AndroidManifest.xml index 446c4606d7..941f4ef1a8 100644 --- a/libraries/androidutils/src/main/AndroidManifest.xml +++ b/libraries/androidutils/src/main/AndroidManifest.xml @@ -7,5 +7,4 @@ --> - diff --git a/libraries/matrix/api/src/main/AndroidManifest.xml b/libraries/mediaviewer/impl/src/main/AndroidManifest.xml similarity index 60% rename from libraries/matrix/api/src/main/AndroidManifest.xml rename to libraries/mediaviewer/impl/src/main/AndroidManifest.xml index 608f16cc36..3e5f3ecc7e 100644 --- a/libraries/matrix/api/src/main/AndroidManifest.xml +++ b/libraries/mediaviewer/impl/src/main/AndroidManifest.xml @@ -1,12 +1,12 @@ - + + diff --git a/features/messages/impl/src/main/AndroidManifest.xml b/libraries/voicerecorder/impl/src/main/AndroidManifest.xml similarity index 100% rename from features/messages/impl/src/main/AndroidManifest.xml rename to libraries/voicerecorder/impl/src/main/AndroidManifest.xml From 55f3a1acfbfcffee27f7db303ea8e3a3720ad4e4 Mon Sep 17 00:00:00 2001 From: manfrommedan Date: Wed, 8 Jul 2026 15:32:01 +0300 Subject: [PATCH 60/64] Keep the in-progress edit when attaching media Picking an attachment reset the composer to Normal, which dropped an in-progress edit: the attachment went out as a new message and the leftover edit text then created another one instead of editing the original. Route every attach path through a single resetComposerModeAfterAttaching() helper that clears Reply/Normal but keeps an edit pending, so the typed text still edits the original on the next send. This also covers the multi-item gallery picker added in #6519, which reset the mode unconditionally. Reply is still cleared (the attachment becomes the reply). Fixes #7083. --- .../MessageComposerPresenter.kt | 16 ++-- .../MessageComposerPresenterTest.kt | 74 +++++++++++++++++++ .../mediapickers/test/FakePickerProvider.kt | 9 ++- 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 858e872365..eaa00f427e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -288,8 +288,7 @@ class MessageComposerPresenter( inReplyToEventId = inReplyToEventId, ) - // Reset composer since the attachment has been sent - messageComposerContext.composerMode = MessageComposerMode.Normal + resetComposerModeAfterAttaching() } is MessageComposerEvent.SetMode -> { localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState) @@ -638,8 +637,7 @@ class MessageComposerPresenter( val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId) - // Reset composer since the attachment will be sent in a separate flow - messageComposerContext.composerMode = MessageComposerMode.Normal + resetComposerModeAfterAttaching() } private fun handlePickedMediaList( @@ -663,7 +661,15 @@ class MessageComposerPresenter( val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) - messageComposerContext.composerMode = MessageComposerMode.Normal + resetComposerModeAfterAttaching() + } + + private fun resetComposerModeAfterAttaching() { + // An attachment is sent as its own message, so a Reply/Normal mode is consumed and reset. + // An in-progress edit must survive: the typed text still edits the original on the next send. + if (!messageComposerContext.composerMode.isEditing) { + messageComposerContext.composerMode = MessageComposerMode.Normal + } } private suspend fun sendMedia( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 7c6f47058e..499e4bf91a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -758,6 +759,79 @@ class MessageComposerPresenterTest : RobolectricTest() { } } + @Test + fun `present - Pick media from gallery while editing keeps the edit pending`() = runTest { + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter(navigator = navigator) + pickerProvider.givenMimeType(MimeTypes.Images) + presenter.test { + var state = awaitFirstItem() + val editMode = anEditMode() + state.eventSink(MessageComposerEvent.SetMode(editMode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(editMode) + // The media is sent as a new message while the edit stays active. If the mode reset, + // a Normal-mode state would be emitted and expectNoEvents() below would fail. + state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) + onPreviewAttachmentLambda.assertions().isCalledOnce() + expectNoEvents() + assertThat(state.mode).isEqualTo(editMode) + } + } + + @Test + fun `present - Pick media from gallery while replying clears the reply mode`() = runTest { + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter(navigator = navigator) + pickerProvider.givenMimeType(MimeTypes.Images) + presenter.test { + var state = awaitFirstItem() + state.eventSink(MessageComposerEvent.SetMode(aReplyMode())) + state = awaitItem() + assertThat(state.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + // The media becomes the reply, so the reply intent is consumed and the composer resets. + state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) + onPreviewAttachmentLambda.assertions().isCalledOnce() + state = awaitItem() + assertThat(state.mode).isEqualTo(MessageComposerMode.Normal) + } + } + + @Test + fun `present - Pick multiple media from gallery while editing keeps the edit pending`() = runTest { + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter( + navigator = navigator, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.SendGalleryMessages.key to true) + ), + ) + pickerProvider.givenMimeType(MimeTypes.Images) + // Two Uris take the multi-item branch (handlePickedMediaList) instead of delegating to the + // single-item path, so the edit is preserved for gallery messages too. + pickerProvider.givenMultipleResults(listOf(mockk(), mockk())) + presenter.test { + var state = awaitFirstItem() + val editMode = anEditMode() + state.eventSink(MessageComposerEvent.SetMode(editMode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(editMode) + state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) + onPreviewAttachmentLambda.assertions().isCalledOnce() + expectNoEvents() + assertThat(state.mode).isEqualTo(editMode) + } + } + @Test fun `present - Pick media from gallery & cancel does nothing`() = runTest { val presenter = createPresenter() diff --git a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt index 5d3be8acd1..022ad05da2 100644 --- a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt +++ b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider class FakePickerProvider : PickerProvider { private var mimeType = MimeTypes.Any private var result: Uri? = null + private var multipleResults: List? = null @Composable override fun registerGalleryPicker(onResult: (uri: Uri?, mimeType: String?) -> Unit): PickerLauncher { @@ -32,7 +33,7 @@ class FakePickerProvider : PickerProvider { @Composable override fun registerGalleryMultiPicker(onResult: (uris: List) -> Unit): PickerLauncher> { - return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) } + return NoOpPickerLauncher { onResult(multipleResults ?: result?.let { listOf(it) } ?: emptyList()) } } @Composable @@ -42,7 +43,7 @@ class FakePickerProvider : PickerProvider { @Composable override fun registerFileMultiPicker(mimeType: String, onResult: (uris: List) -> Unit): PickerLauncher, List> { - return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) } + return NoOpPickerLauncher { onResult(multipleResults ?: result?.let { listOf(it) } ?: emptyList()) } } @Composable @@ -59,6 +60,10 @@ class FakePickerProvider : PickerProvider { this.result = value } + fun givenMultipleResults(uris: List) { + this.multipleResults = uris + } + fun givenMimeType(mimeType: String) { this.mimeType = mimeType } From 3dabece5f8fe04abc907fa1ef295db39befd2c27 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Jul 2026 17:29:51 +0200 Subject: [PATCH 61/64] Restore AndroidManifest.xml to fix lint warning. --- features/messages/impl/src/main/AndroidManifest.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 features/messages/impl/src/main/AndroidManifest.xml diff --git a/features/messages/impl/src/main/AndroidManifest.xml b/features/messages/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..ed135aef7a --- /dev/null +++ b/features/messages/impl/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + From fad8dce1667589b8ff4295990df0a5f5ef0113f1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 8 Jul 2026 18:16:13 +0200 Subject: [PATCH 62/64] Address PR review comments --- .../impl/userstatus/UserStatusView.kt | 4 -- .../userstatus/UserStatusPresenterTest.kt | 40 +++++-------------- .../libraries/matrix/impl/RustMatrixClient.kt | 1 + 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index dfc68ff446..7169aaec66 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.maxLength import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -121,9 +120,6 @@ private fun EmptyStatusRow( modifier = Modifier.padding(vertical = 16.dp), ) }, - trailingContent = ListItemContent.Custom({ - Box(modifier = Modifier.minimumInteractiveComponentSize()) - }), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), modifier = modifier.clickable(onClick = onClick), ) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt index 4a03a22607..3f78843b74 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -7,13 +7,11 @@ package io.element.android.features.preferences.impl.userstatus -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.test import kotlinx.coroutines.test.runTest import org.junit.Test @@ -23,9 +21,7 @@ class UserStatusPresenterTest { @Test fun `initial state - no status set`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { val state = awaitItem() assertThat(state.displayedStatus).isNull() assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden) @@ -34,9 +30,7 @@ class UserStatusPresenterTest { @Test fun `Open event transitions to ShowingPicker`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { val state = awaitItem() state.eventSink(UserStatusEvent.OpenPicker) assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker) @@ -45,9 +39,7 @@ class UserStatusPresenterTest { @Test fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { val state = awaitItem() state.eventSink(UserStatusEvent.OpenPicker) awaitItem() // ShowingPicker @@ -59,9 +51,7 @@ class UserStatusPresenterTest { @Test fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest { val client = FakeMatrixClient() - moleculeFlow(RecompositionMode.Immediate) { - createPresenter(client).present() - }.test { + createPresenter(client).test { val status = UserStatus(emoji = "💬", text = "In a meeting") awaitItem().eventSink(UserStatusEvent.SetStatus(status)) val newState = awaitItem() @@ -73,9 +63,7 @@ class UserStatusPresenterTest { @Test fun `OpenCustomInput with no existing status uses defaults`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput @@ -87,9 +75,7 @@ class UserStatusPresenterTest { @Test fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest { val client = FakeMatrixClient() - moleculeFlow(RecompositionMode.Immediate) { - createPresenter(client).present() - }.test { + createPresenter(client).test { val status = UserStatus(emoji = "🌴", text = "Away") awaitItem().eventSink(UserStatusEvent.SetStatus(status)) // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition @@ -103,9 +89,7 @@ class UserStatusPresenterTest { @Test fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("🚀")) val state = awaitItem() @@ -115,9 +99,7 @@ class UserStatusPresenterTest { @Test fun `CancelCustomInput goes back to Hidden`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createPresenter().present() - }.test { + createPresenter().test { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) awaitItem().eventSink(UserStatusEvent.CancelCustomInput) // CustomInput state, fire Cancel assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden) @@ -127,9 +109,7 @@ class UserStatusPresenterTest { @Test fun `Clear event calls clearUserStatus and clears displayed status`() = runTest { val client = FakeMatrixClient() - moleculeFlow(RecompositionMode.Immediate) { - createPresenter(client).present() - }.test { + createPresenter(client).test { val status = UserStatus(emoji = "☕", text = "Be right back") awaitItem().eventSink(UserStatusEvent.SetStatus(status)) // Single emission: pickerState=Hidden and displayedStatus both set in one recomposition diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index af6762d421..6ddca47bff 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -456,6 +456,7 @@ class RustMatrixClient( displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) }, ) ) + // Also update our session storage sessionStore.updateUserProfile( sessionId = sessionId.value, displayName = matrixUser.displayName, From 6296a538ebc251a6d1dc6de8b6f72227336e8e43 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:52:45 +0200 Subject: [PATCH 63/64] Update metro to v1.3.0 (#7168) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef95884507..4db11a279e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,7 @@ haze = "1.7.2" dependencyAnalysis = "3.16.0" # DI -metro = "1.2.1" +metro = "1.3.0" # Auto service autoservice = "1.1.1" From 96d443e443e0b2ce06b4b3e85669312dce69c1b6 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 9 Jul 2026 13:51:37 +0200 Subject: [PATCH 64/64] Fix compilation warning --- .../preferences/impl/userstatus/UserStatusView.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index 7169aaec66..acd2b0676d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -22,7 +22,8 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.maxLength import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -177,7 +178,10 @@ private fun UserStatusPickerBottomSheet( onSelectCustomStatus: () -> Unit, modifier: Modifier = Modifier, ) { - val sheetState = rememberModalBottomSheetState() + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded) + ) val coroutineScope = rememberCoroutineScope() ModalBottomSheet( onDismissRequest = onDismiss,