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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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 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 08/15] 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 09/15] 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 10/15] 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 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 11/15] 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 387022a9af91290525489ed3295035c681a98b54 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Jul 2026 14:24:52 +0200 Subject: [PATCH 12/15] 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 13/15] 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 14/15] 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 15/15] 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