Extract EmojiPicker into libraries/emoji with Renderer/Presenter pattern

This commit is contained in:
ganfra
2026-07-17 22:11:23 +02:00
parent e492f06386
commit 5d8290b240
74 changed files with 571 additions and 804 deletions
@@ -0,0 +1,12 @@
/*
* 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.emoji.api.picker
import io.element.android.libraries.architecture.Presenter
fun interface EmojiPickerPresenter : Presenter<EmojiPickerState>
@@ -0,0 +1,33 @@
/*
* 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.emoji.api.picker
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji
import kotlinx.collections.immutable.ImmutableSet
@Immutable
interface EmojiPickerRenderer {
/**
* @param state opaque state produced by [EmojiPickerPresenter.present].
* @param onSelectEmoji invoked when the user taps an emoji (including a skin-tone variant).
* @param selectedEmojis emojis to visually mark as already selected.
* @param modifier layout modifier for the picker container.
* @param contentDescription accessibility label for each emoji cell.
*/
@Composable
fun Render(
state: EmojiPickerState,
onSelectEmoji: (Emoji) -> Unit,
selectedEmojis: ImmutableSet<String>,
modifier: Modifier = Modifier,
contentDescription: @Composable (Emoji, Boolean) -> String = { emoji, _ -> emoji.unicode },
)
}
@@ -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.emoji.api.picker
/**
* Opaque state produced by an [EmojiPickerPresenter] and consumed by an [EmojiPickerRenderer].
*/
interface EmojiPickerState {
/**
* `true` once the picker has finished loading its data and is safe to display.
* Callers should typically defer showing the picker (or its container, e.g. a bottom sheet)
* until this flips to `true` to avoid flashing an empty picker.
*/
val isReady: Boolean
}
-1
View File
@@ -35,7 +35,6 @@ dependencies {
implementation(libs.kotlinx.collections.immutable)
implementation(libs.matrix.emojibase.bindings)
testImplementation(projects.libraries.emoji.test)
testImplementation(projects.libraries.matrix.test)
testCommonDependencies(libs)
}
@@ -11,16 +11,28 @@ package io.element.android.libraries.emoji.impl
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.emojibasebindings.EmojibaseDatasource
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.emoji.api.EmojibaseProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultEmojibaseProvider(@ApplicationContext val context: Context) : EmojibaseProvider {
override val emojibaseStore: EmojibaseStore by lazy {
class DefaultEmojibaseProvider(
@ApplicationContext private val context: Context,
dispatchers: CoroutineDispatchers,
@AppCoroutineScope appScope: CoroutineScope,
) : EmojibaseProvider {
private val deferred = appScope.async(dispatchers.io, start = CoroutineStart.LAZY) {
EmojibaseDatasource().load(context)
}
override suspend fun getStore(): EmojibaseStore = deferred.await()
}
@@ -1,15 +1,14 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.
*/
package io.element.android.libraries.emoji.api
package io.element.android.libraries.emoji.impl
import io.element.android.emojibasebindings.EmojibaseStore
interface EmojibaseProvider {
val emojibaseStore: EmojibaseStore
suspend fun getStore(): EmojibaseStore
}
@@ -0,0 +1,133 @@
/*
* 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.
*/
package io.element.android.libraries.emoji.impl.picker
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalInspectionMode
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
import io.element.android.libraries.emoji.impl.EmojibaseProvider
import io.element.android.libraries.emoji.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
@Inject
@ContributesBinding(SessionScope::class)
class DefaultEmojiPickerPresenter(
private val emojibaseProvider: EmojibaseProvider,
private val getRecentEmojis: GetRecentEmojis,
private val coroutineDispatchers: CoroutineDispatchers,
) : EmojiPickerPresenter {
@Composable
override fun present(): EmojiPickerState {
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var emojiResults by remember { mutableStateOf<SearchBarResultState<ImmutableList<Emoji>>>(SearchBarResultState.Initial()) }
val data by produceState(EmojiPickerData.Empty) {
val storeDeferred = async { emojibaseProvider.getStore() }
val recentsDeferred = async { getRecentEmojis().getOrNull() ?: persistentListOf() }
val store = storeDeferred.await()
val recentEmojiUnicodes = recentsDeferred.await()
value = withContext(coroutineDispatchers.computation) {
val baseCategories = store.categories.map { (category, emojis) ->
EmojiCategory(
titleId = category.title,
icon = IconSource.Vector(category.icon),
emojis = emojis,
)
}
val recentEmojis = recentEmojiUnicodes
.mapNotNull { unicode -> store.allEmojis.find { it.unicode == unicode } }
.toImmutableList()
val categories = if (recentEmojis.isEmpty()) {
baseCategories.toImmutableList()
} else {
val recentCategory = EmojiCategory(
titleId = R.string.emoji_picker_category_recent,
icon = IconSource.Resource(io.element.android.compound.R.drawable.ic_compound_history),
emojis = recentEmojis,
)
(listOf(recentCategory) + baseCategories).toImmutableList()
}
EmojiPickerData(
categories = categories,
allEmojis = store.allEmojis,
)
}
}
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery, data) {
if (searchQuery.isEmpty() || data.allEmojis.isEmpty()) {
emojiResults = SearchBarResultState.Initial()
return@LaunchedEffect
}
delay(100.milliseconds)
val lowercaseQuery = searchQuery.lowercase()
val results = withContext(coroutineDispatchers.computation) {
data.allEmojis
.asSequence()
.filter { emoji ->
emoji.tags.orEmpty().any { it.contains(lowercaseQuery) } ||
emoji.shortcodes.any { it.contains(lowercaseQuery) }
}
.take(60)
.toImmutableList()
}
emojiResults = SearchBarResultState.Results(results)
}
val isInPreview = LocalInspectionMode.current
fun handleEvent(event: EmojiPickerEvent) {
when (event) {
is EmojiPickerEvent.ToggleSearchActive -> if (!isInPreview) {
isSearchActive = event.isActive
}
}
}
return DefaultEmojiPickerState(
categories = data.categories,
searchQuery = queryState,
isSearchActive = isSearchActive,
searchResults = emojiResults,
eventSink = ::handleEvent,
)
}
}
private data class EmojiPickerData(
val categories: ImmutableList<EmojiCategory>,
val allEmojis: ImmutableList<Emoji>,
) {
companion object {
val Empty = EmojiPickerData(persistentListOf(), persistentListOf())
}
}
@@ -0,0 +1,43 @@
/*
* 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.emoji.impl.picker
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import kotlinx.collections.immutable.ImmutableSet
@Inject
@ContributesBinding(SessionScope::class)
class DefaultEmojiPickerRenderer : EmojiPickerRenderer {
@Composable
override fun Render(
state: EmojiPickerState,
onSelectEmoji: (Emoji) -> Unit,
selectedEmojis: ImmutableSet<String>,
modifier: Modifier,
contentDescription: @Composable (Emoji, Boolean) -> String,
) {
if (state is DefaultEmojiPickerState) {
EmojiPickerView(
state = state,
onSelectEmoji = onSelectEmoji,
selectedEmojis = selectedEmojis,
contentDescription = contentDescription,
modifier = modifier,
)
} else {
error("Unsupported state type: ${state::class.java}")
}
}
}
@@ -0,0 +1,35 @@
/*
* 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.
*/
package io.element.android.libraries.emoji.impl.picker
import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class DefaultEmojiPickerState(
val categories: ImmutableList<EmojiCategory>,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<Emoji>>,
val eventSink: (EmojiPickerEvent) -> Unit,
) : EmojiPickerState {
override val isReady = categories.isNotEmpty()
}
internal data class EmojiCategory(
@StringRes val titleId: Int,
val icon: IconSource,
val emojis: ImmutableList<Emoji>,
)
@@ -0,0 +1,92 @@
/*
* 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.
*/
package io.element.android.libraries.emoji.impl.picker
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class DefaultEmojiPickerStateProvider : PreviewParameterProvider<DefaultEmojiPickerState> {
override val values: Sequence<DefaultEmojiPickerState>
get() = sequenceOf(
aDefaultEmojiPickerState(),
aDefaultEmojiPickerState(isSearchActive = true),
aDefaultEmojiPickerState(isSearchActive = true, searchQuery = "smile"),
aDefaultEmojiPickerState(
isSearchActive = true,
searchQuery = "smile",
searchResults = SearchBarResultState.Results(emojiList())
),
)
}
private fun emojiList(): ImmutableList<Emoji> = persistentListOf(
Emoji(
"0x00",
"grinning face",
persistentListOf("grinning"),
persistentListOf("smile, grin"),
"😀",
null
),
Emoji(
"0x01",
"crying face",
persistentListOf("crying"),
persistentListOf("smile, crying"),
"🥲",
null
)
)
internal fun aDefaultEmojiPickerState(
categories: ImmutableList<EmojiCategory> = EmojibaseCategory.entries.map {
EmojiCategory(
titleId = it.title,
icon = IconSource.Vector(it.icon),
emojis = emojiList(),
)
}.toImmutableList(),
searchQuery: String = "",
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<Emoji>> = SearchBarResultState.Initial(),
eventSink: (EmojiPickerEvent) -> Unit = {},
) = DefaultEmojiPickerState(
categories = categories,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
searchResults = searchResults,
eventSink = eventSink,
)
/**
* Public helper for external tests that need an [EmojiPickerState] instance the impl's
* [DefaultEmojiPickerRenderer] will accept. The no-arg overload returns a ready state
* (see [EmojiPickerState.isReady]); use the [emojis] overload to inject specific categories.
*/
fun anEmojiPickerState(): EmojiPickerState = aDefaultEmojiPickerState()
fun anEmojiPickerState(
emojis: Map<EmojibaseCategory, ImmutableList<Emoji>>,
): EmojiPickerState = aDefaultEmojiPickerState(
categories = emojis.map { (category, list) ->
EmojiCategory(
titleId = category.title,
icon = IconSource.Vector(category.icon),
emojis = list,
)
}.toImmutableList(),
)
@@ -0,0 +1,303 @@
/*
* 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.emoji.impl.picker
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
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.padding
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.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
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.geometry.Offset
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
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.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
internal fun EmojiItem(
item: Emoji,
isSelected: Boolean,
onSelectEmoji: (Emoji) -> Unit,
onLongPress: (Emoji) -> Unit,
onDismissSkinPicker: () -> Unit,
skinPickerEmoji: Emoji?,
selectedSkinUnicodes: ImmutableSet<String>,
hasSelectedSkin: Boolean,
contentDescription: @Composable (emoji: Emoji, isSelected: Boolean) -> String = { emoji, _ -> emoji.unicode },
modifier: Modifier = Modifier,
emojiSize: TextUnit = 20.sp,
) {
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 itemOffsetPercent by remember { mutableFloatStateOf(0f) }
var hoveredIndex by remember { mutableIntStateOf(-1) }
var dismissed by remember { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val description = contentDescription(item, isSelected)
val hasSkinTones = !item.skins.isNullOrEmpty()
Box(
modifier = modifier
.onPlaced { coordinates ->
val position = coordinates.positionInParent()
val parentBounds = coordinates.parentLayoutCoordinates?.size ?: return@onPlaced
itemOffsetPercent = if (parentBounds.width > 0) {
position.x / parentBounds.width
} else {
0f
}
}
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
.onSizeChanged { itemSize = it }
.background(backgroundColor, CircleShape)
.indication(interactionSource, ripple())
.pointerInput(item) {
detectTapGestures(
onPress = { pressOffset ->
val press = PressInteraction.Press(pressOffset)
interactionSource.emit(press)
if (tryAwaitRelease()) {
interactionSource.emit(PressInteraction.Release(press))
if (hasSkinTones) {
onSelectEmoji(item)
}
} else {
interactionSource.emit(PressInteraction.Cancel(press))
}
},
onTap = { onSelectEmoji(item) },
)
}
.then(
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 ->
startOffset = position
dismissed = false
onLongPress(item)
hoveredIndex = -1
},
onDrag = { change, _ ->
if (!dismissed) {
change.consume()
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()
} else {
val skinItemsCount = item.skins!!.size
val totalSlots = 1 + skinItemsCount
val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx
val initialOffset = pickerWidthPx * itemOffsetPercent
val xInPicker = initialOffset + change.position.x
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()
},
onDragCancel = {
dismissed = false
hoveredIndex = -1
},
)
}
} else {
Modifier
}
)
.clearAndSetSemantics {
this.contentDescription = description
},
contentAlignment = Alignment.Center
) {
Text(
text = item.unicode,
style = LocalTextStyle.current.copy(fontSize = emojiSize),
)
if (hasSkinTones) {
val pickerIndicatorColor = ElementTheme.colors.borderInteractivePrimary
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 = pickerIndicatorColor, style = Fill)
}
}
if (skinPickerEmoji == item) {
val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize
val popupOffsetPx = with(density) { -pickerHeight.roundToPx() }
Popup(
onDismissRequest = { onDismissSkinPicker() },
alignment = Alignment.BottomCenter,
offset = IntOffset(0, popupOffsetPx),
) {
SkinTonePicker(
emoji = item,
onSelect = { selectedEmoji ->
onSelectEmoji(selectedEmoji)
onDismissSkinPicker()
},
hoveredIndex = hoveredIndex,
selectedUnicodes = selectedSkinUnicodes,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun EmojiItemPreview() = ElementPreview {
Row(
modifier = Modifier.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
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,
onLongPress = {},
onDismissSkinPicker = {},
skinPickerEmoji = null,
selectedSkinUnicodes = persistentSetOf(),
)
}
}
}
}
}
@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("👍🏾"),
)
}
}
@@ -0,0 +1,13 @@
/*
* 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.
*/
package io.element.android.libraries.emoji.impl.picker
internal sealed interface EmojiPickerEvent {
data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvent
}
@@ -0,0 +1,176 @@
/*
* 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.emoji.impl.picker
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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
import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji
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.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun EmojiPickerView(
state: DefaultEmojiPickerState,
onSelectEmoji: (Emoji) -> Unit,
selectedEmojis: ImmutableSet<String>,
modifier: Modifier = Modifier,
contentDescription: @Composable (emoji: Emoji, isSelected: Boolean) -> String = { emoji, _ -> emoji.unicode },
) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { state.categories.size })
var skinPickerEmoji by remember { mutableStateOf<Emoji?>(null) }
Column(modifier) {
SearchBar(
modifier = Modifier.padding(bottom = 10.dp),
queryState = state.searchQuery,
resultState = state.searchResults,
active = state.isSearchActive,
onActiveChange = { state.eventSink(EmojiPickerEvent.ToggleSearchActive(it)) },
windowInsets = WindowInsets(0, 0, 0, 0),
placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder),
) { emojis ->
EmojiResults(
emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji,
onLongPress = { skinPickerEmoji = it },
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = { skinPickerEmoji = null },
selectedEmojis = selectedEmojis,
contentDescription = contentDescription,
)
}
if (!state.isSearchActive) {
SecondaryTabRow(
selectedTabIndex = pagerState.currentPage,
) {
state.categories.forEachIndexed { index, category ->
Tab(
icon = {
when (category.icon) {
is IconSource.Resource -> Icon(
resourceId = category.icon.id,
contentDescription = stringResource(id = category.titleId)
)
is IconSource.Vector -> Icon(
imageVector = category.icon.vector,
contentDescription = stringResource(id = category.titleId)
)
}
},
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
}
)
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { index ->
val emojis = state.categories[index].emojis
EmojiResults(
emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji,
onLongPress = { skinPickerEmoji = it },
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = { skinPickerEmoji = null },
selectedEmojis = selectedEmojis,
contentDescription = contentDescription,
)
}
}
}
}
@Composable
private fun EmojiResults(
emojis: ImmutableList<Emoji>,
isEmojiSelected: (Emoji) -> Boolean,
onSelectEmoji: (Emoji) -> Unit,
onLongPress: (Emoji) -> Unit,
skinPickerEmoji: Emoji?,
onDismissSkinPicker: () -> Unit,
selectedEmojis: ImmutableSet<String>,
contentDescription: @Composable (emoji: Emoji, isSelected: Boolean) -> String,
) {
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Adaptive(minSize = 48.dp),
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(emojis, key = { it.unicode }) { item ->
EmojiItem(
modifier = Modifier.aspectRatio(1f),
item = item,
isSelected = isEmojiSelected(item),
onSelectEmoji = onSelectEmoji,
onLongPress = onLongPress,
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = onDismissSkinPicker,
emojiSize = 32.dp.toSp(),
selectedSkinUnicodes = selectedEmojis,
hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true,
contentDescription = contentDescription,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun EmojiPickerViewPreview(@PreviewParameter(DefaultEmojiPickerStateProvider::class) state: DefaultEmojiPickerState) = ElementPreview {
EmojiPickerView(
state = state,
onSelectEmoji = {},
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
modifier = Modifier.fillMaxWidth(),
)
}
@@ -0,0 +1,48 @@
/*
* 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.emoji.impl.picker
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.EmojiFlags
import androidx.compose.material.icons.outlined.EmojiFoodBeverage
import androidx.compose.material.icons.outlined.EmojiNature
import androidx.compose.material.icons.outlined.EmojiObjects
import androidx.compose.material.icons.outlined.EmojiPeople
import androidx.compose.material.icons.outlined.EmojiSymbols
import androidx.compose.material.icons.outlined.EmojiTransportation
import androidx.compose.ui.graphics.vector.ImageVector
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.libraries.emoji.impl.R
@get:StringRes
internal val EmojibaseCategory.title: Int
get() = when (this) {
EmojibaseCategory.People -> R.string.emoji_picker_category_people
EmojibaseCategory.Nature -> R.string.emoji_picker_category_nature
EmojibaseCategory.Foods -> R.string.emoji_picker_category_foods
EmojibaseCategory.Activity -> R.string.emoji_picker_category_activity
EmojibaseCategory.Places -> R.string.emoji_picker_category_places
EmojibaseCategory.Objects -> R.string.emoji_picker_category_objects
EmojibaseCategory.Symbols -> R.string.emoji_picker_category_symbols
EmojibaseCategory.Flags -> R.string.emoji_picker_category_flags
}
internal val EmojibaseCategory.icon: ImageVector
get() = when (this) {
EmojibaseCategory.People -> Icons.Outlined.EmojiPeople
EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature
EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage
EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents
EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation
EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects
EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols
EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags
}
@@ -0,0 +1,137 @@
/*
* 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.emoji.impl.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.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
internal val SkinToneSlotSize = 48.dp
internal val SkinToneSlotSpacing = 2.dp
internal val SkinTonePadding = 4.dp
@Composable
internal fun SkinTonePicker(
emoji: Emoji,
hoveredIndex: Int,
selectedUnicodes: ImmutableSet<String>,
onSelect: (Emoji) -> Unit,
modifier: Modifier = Modifier,
) {
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 SkinTonePickerPreview() = ElementPreview {
SkinTonePicker(
emoji = Emoji(
hexcode = "1F44D",
label = "thumbs up",
tags = null,
shortcodes = persistentListOf("+1", "thumbsup"),
unicode = "👍",
skins = aSkinList(),
),
hoveredIndex = -1,
selectedUnicodes = persistentSetOf(),
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", "👍🏿"),
)
@@ -11,8 +11,8 @@ package io.element.android.libraries.emoji.impl.recentemojis
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.EmojibaseProvider
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
import io.element.android.libraries.emoji.impl.EmojibaseProvider
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -25,7 +25,7 @@ class DefaultGetRecentEmojis(
private val emojibaseProvider: EmojibaseProvider,
) : GetRecentEmojis {
override suspend operator fun invoke(): Result<ImmutableList<String>> = withContext(dispatchers.io) {
val allEmojis = emojibaseProvider.emojibaseStore.allEmojis
val allEmojis = emojibaseProvider.getStore().allEmojis
client.getRecentEmojis()
.map { emojis ->
// Remove any possible duplicates
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Migrated from features/messages/impl. Localazy config must be updated to write these keys here. -->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="emoji_picker_category_activity">"Activities"</string>
<string name="emoji_picker_category_flags">"Flags"</string>
<string name="emoji_picker_category_foods">"Food &amp; Drink"</string>
<string name="emoji_picker_category_nature">"Animals &amp; Nature"</string>
<string name="emoji_picker_category_objects">"Objects"</string>
<string name="emoji_picker_category_people">"Smileys &amp; People"</string>
<string name="emoji_picker_category_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_recent">"Recent emojis"</string>
<string name="emoji_picker_category_symbols">"Symbols"</string>
</resources>
@@ -6,18 +6,18 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.emoji.test
package io.element.android.libraries.emoji.impl.fixtures
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.libraries.emoji.api.EmojibaseProvider
import io.element.android.libraries.emoji.impl.EmojibaseProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentMap
class FakeEmojibaseProvider(
val emojis: Map<EmojibaseCategory, ImmutableList<Emoji>> = mapOf(),
internal class FakeEmojibaseProvider(
emojis: Map<EmojibaseCategory, ImmutableList<Emoji>> = mapOf(),
) : EmojibaseProvider {
override val emojibaseStore: EmojibaseStore
get() = EmojibaseStore(emojis.toPersistentMap())
private val store = EmojibaseStore(emojis.toPersistentMap())
override suspend fun getStore(): EmojibaseStore = store
}
@@ -0,0 +1,112 @@
/*
* 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.
*/
package io.element.android.libraries.emoji.impl.picker
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.turbine.TurbineTestContext
import com.google.common.truth.Truth.assertThat
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
import io.element.android.libraries.emoji.impl.fixtures.FakeEmojibaseProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class EmojiPickerPresenterTest : RobolectricTest() {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `updating search query loads new results`() = runTest {
createPresenter().test {
val initialState = awaitStoreLoaded()
assertThat(initialState.searchQuery.text.toString()).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("smile")
val stateAfterQuery = awaitItem() as DefaultEmojiPickerState
assertThat(stateAfterQuery.searchQuery.text.toString()).isEqualTo("smile")
val stateWithResults = awaitItem() as DefaultEmojiPickerState
assertThat(stateWithResults.searchQuery.text.toString()).isEqualTo("smile")
assertThat(stateWithResults.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
}
}
@Test
fun `recent emojis are prepended as a category when the source returns any`() = runTest {
createPresenter(
categories = persistentListOf(emojiCategory(EmojibaseCategory.Activity)),
getRecentEmojis = GetRecentEmojis { Result.success(persistentListOf("😊")) },
).test {
val state = awaitStoreLoaded()
assertThat(state.categories.size).isEqualTo(2)
}
}
@Test
fun `ToggleSearchActive toggles the search state`() = runTest {
createPresenter().test {
val initialState = awaitStoreLoaded()
assertThat(initialState.isSearchActive).isFalse()
initialState.eventSink(EmojiPickerEvent.ToggleSearchActive(true))
assertThat((awaitItem() as DefaultEmojiPickerState).isSearchActive).isTrue()
initialState.eventSink(EmojiPickerEvent.ToggleSearchActive(false))
assertThat((awaitItem() as DefaultEmojiPickerState).isSearchActive).isFalse()
}
}
@Test
fun `presenter exposes categories from the emoji store when the recent source is empty`() = runTest {
val providedCategories = persistentListOf(
emojiCategory(EmojibaseCategory.Activity),
emojiCategory(EmojibaseCategory.People),
)
createPresenter(categories = providedCategories).test {
val state = awaitStoreLoaded()
assertThat(state.categories.size).isEqualTo(providedCategories.size)
}
}
private suspend fun TurbineTestContext<EmojiPickerState>.awaitStoreLoaded(): DefaultEmojiPickerState {
var state = awaitItem() as DefaultEmojiPickerState
while (state.categories.isEmpty()) {
state = awaitItem() as DefaultEmojiPickerState
}
return state
}
private fun TestScope.createPresenter(
categories: ImmutableList<Pair<EmojibaseCategory, ImmutableList<Emoji>>> = persistentListOf(emojiCategory()),
getRecentEmojis: GetRecentEmojis = GetRecentEmojis { Result.success(persistentListOf()) },
) = DefaultEmojiPickerPresenter(
emojibaseProvider = FakeEmojibaseProvider(emojis = categories.toMap()),
getRecentEmojis = getRecentEmojis,
coroutineDispatchers = testCoroutineDispatchers(),
)
private fun emojiCategory(
category: EmojibaseCategory = EmojibaseCategory.Activity,
emojis: ImmutableList<Emoji> = persistentListOf(
Emoji("1F3C3", "Smile", persistentListOf("smile"), persistentListOf("smile"), "😊", skins = null)
)
) = category to emojis
}
@@ -12,7 +12,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.emojibasebindings.EmojibaseCategory.People
import io.element.android.libraries.emoji.test.FakeEmojibaseProvider
import io.element.android.libraries.emoji.impl.fixtures.FakeEmojibaseProvider
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.ImmutableList
-25
View File
@@ -1,25 +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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.emoji.test"
}
dependencies {
api(projects.libraries.emoji.api)
api(projects.libraries.matrix.api)
api(libs.coroutines.core)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.coroutines.test)
implementation(libs.matrix.emojibase.bindings)
implementation(projects.tests.testutils)
}