desktop: add emoji picker (#7708)

* desktop: add emoji picker data and generator

* desktop: emoji picker in compose field

* desktop: document compose emoji picker
This commit is contained in:
sh authored and GitHub committed 2026-10-16 14:34:04 +01:00
1 parent fba65d98ee
commit aaa388e570
15 files changed
+2429 -7

No files matched your search

@@ -0,0 +1,7 @@
package chat.simplex.common.views.chat
import androidx.compose.runtime.*
// Android keyboards have their own emoji panel
@Composable
actual fun ComposeEmojiButton(pickerShown: MutableState<Boolean>, enabled: Boolean, onEmoji: (String) -> Unit) {}
@@ -0,0 +1,14 @@
package chat.simplex.common.views.chat
import androidx.compose.runtime.*
import androidx.compose.ui.text.TextRange
import kotlin.math.min
@Composable
expect fun ComposeEmojiButton(pickerShown: MutableState<Boolean>, enabled: Boolean, onEmoji: (String) -> Unit)
fun ComposeMessage.insertingEmoji(emoji: String): ComposeMessage {
val start = min(selection.min, text.length)
val end = min(selection.max, text.length)
return ComposeMessage(text.replaceRange(start, end, emoji), TextRange(start + emoji.length))
}
@@ -454,6 +454,7 @@ fun ComposeView(
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) }
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
val emojiPickerShown = rememberSaveable { mutableStateOf(false) }
AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } }
suspend fun fetchAndUpdateLinkPreview(url: String) {
@@ -1278,6 +1279,17 @@ fun ComposeView(
val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason((ownerRelayState?.noActiveRelays ?: subscriberRelayState?.noActiveRelays) == true))
val sendMsgEnabled = rememberUpdatedState(userCantSendReason.value == null)
val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv)
val emojiEnabled = sendMsgEnabled.value && composeState.value.preview !is ComposePreview.VoicePreview
LaunchedEffect(emojiEnabled) {
if (!emojiEnabled) emojiPickerShown.value = false
}
KeyChangeEffect(emojiPickerShown.value) {
if (!emojiPickerShown.value && emojiEnabled) focusRequester?.requestFocus()
}
fun onEmoji(emoji: String) {
if (!composeState.value.inProgress) onMessageChange(composeState.value.message.insertingEmoji(emoji))
}
@Composable
fun CommandsButton() {
@@ -1332,7 +1344,7 @@ fun ComposeView(
}
@Composable
fun AttachmentAndCommandsButtons() {
fun ComposeActionButtons() {
val cInfo = chat.chatInfo
Row(
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
@@ -1346,6 +1358,7 @@ fun ComposeView(
if (showAttachment) {
AttachmentButton()
}
ComposeEmojiButton(emojiPickerShown, emojiEnabled, ::onEmoji)
}
}
@@ -1784,7 +1797,7 @@ fun ComposeView(
ContextSendMessageToConnect(generalGetString(MR.strings.compose_send_direct_message_to_connect))
Divider()
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
AttachmentAndCommandsButtons()
ComposeActionButtons()
SendMsgView_(
disableSendButton = disableSendButton,
sendToConnect = { withApi { sendMemberContactInvitation() } }
@@ -1839,7 +1852,7 @@ fun ComposeView(
)
} else {
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
AttachmentAndCommandsButtons()
ComposeActionButtons()
val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi ->
if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner && chat.chatInfo.groupChatScope() == null) generalGetString(MR.strings.compose_view_broadcast)
else null
@@ -587,6 +587,17 @@
<!-- ComposeView.kt, helpers -->
<string name="attach">Attach</string>
<string name="icon_descr_emoji">Emoji</string>
<string name="emoji_category_smileys">Smileys &amp; emotion</string>
<string name="emoji_category_people">People &amp; body</string>
<string name="emoji_category_animals">Animals &amp; nature</string>
<string name="emoji_category_food">Food &amp; drink</string>
<string name="emoji_category_travel">Travel &amp; places</string>
<string name="emoji_category_activities">Activities</string>
<string name="emoji_category_objects">Objects</string>
<string name="emoji_category_symbols">Symbols</string>
<string name="emoji_category_flags">Flags</string>
<string name="no_emoji_found">No emoji found</string>
<string name="icon_descr_context">Context icon</string>
<string name="icon_descr_cancel_image_preview">Cancel image preview</string>
<string name="icon_descr_cancel_file_preview">Cancel file preview</string>
@@ -0,0 +1,42 @@
package chat.simplex.app
import androidx.compose.ui.text.TextRange
import chat.simplex.common.views.chat.ComposeMessage
import chat.simplex.common.views.chat.insertingEmoji
import kotlin.test.Test
import kotlin.test.assertEquals
// use this command for testing:
// ./gradlew desktopTest
class ComposeEmojiTest {
@Test
fun testInsertIntoEmptyMessage() {
assertEquals(ComposeMessage("😀", TextRange(2)), ComposeMessage().insertingEmoji("😀"))
}
@Test
fun testInsertAtCursor() {
assertEquals(ComposeMessage("hi😀 there", TextRange(4)), ComposeMessage("hi there", TextRange(2)).insertingEmoji("😀"))
assertEquals(ComposeMessage("😀hi", TextRange(2)), ComposeMessage("hi", TextRange(0)).insertingEmoji("😀"))
assertEquals(ComposeMessage("hi👍", TextRange(4)), ComposeMessage("hi").insertingEmoji("👍"))
}
@Test
fun testReplaceSelection() {
assertEquals(ComposeMessage("say ❤️!", TextRange(6)), ComposeMessage("say love!", TextRange(4, 8)).insertingEmoji("❤️"))
// selection made right to left has start > end
assertEquals(ComposeMessage("say ❤️!", TextRange(6)), ComposeMessage("say love!", TextRange(8, 4)).insertingEmoji("❤️"))
}
@Test
fun testInsertAfterEmoji() {
val flag = "🏴󠁧󠁢󠁷󠁬󠁳󠁿"
val withFlag = ComposeMessage(flag)
assertEquals(ComposeMessage(flag + "👩‍💻", TextRange(flag.length + "👩‍💻".length)), withFlag.insertingEmoji("👩‍💻"))
}
@Test
fun testSelectionBeyondTextIsClamped() {
assertEquals(ComposeMessage("ab😀", TextRange(4)), ComposeMessage("ab", TextRange(5)).insertingEmoji("😀"))
}
}
@@ -0,0 +1,248 @@
package chat.simplex.common.views.chat
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.common.ui.theme.EmojiFont
import chat.simplex.common.views.helpers.KeyChangeEffect
import chat.simplex.common.views.helpers.SearchTextField
import chat.simplex.res.*
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.launch
private const val EMOJI_DATA_RESOURCE = "/emoji/emoji.txt"
private const val EMOJI_GROUP_PREFIX = "@"
private val EMOJI_POPUP_WIDTH = 360.dp
private val EMOJI_POPUP_HEIGHT = 420.dp
private val EMOJI_CELL_SIZE = 38.dp
private val EMOJI_SIZE = 26.dp
private val EMOJI_TAB_SIZE = 32.dp
private val EMOJI_TAB_ICON_SIZE = 20.dp
// the buttons left of the message field overlap by 8 dp, this keeps the emoji button a little apart
private val EMOJI_BUTTON_START_PADDING = 4.dp
enum class EmojiCategory(val unicodeGroup: String, val icon: String, val title: StringResource) {
Smileys("Smileys & Emotion", "😀", MR.strings.emoji_category_smileys),
People("People & Body", "👋", MR.strings.emoji_category_people),
Animals("Animals & Nature", "🐻", MR.strings.emoji_category_animals),
Food("Food & Drink", "🍔", MR.strings.emoji_category_food),
Travel("Travel & Places", "🚗", MR.strings.emoji_category_travel),
Activities("Activities", "⚽", MR.strings.emoji_category_activities),
Objects("Objects", "💡", MR.strings.emoji_category_objects),
Symbols("Symbols", "🔣", MR.strings.emoji_category_symbols),
Flags("Flags", "🏁", MR.strings.emoji_category_flags),
}
data class EmojiData(val emoji: String, val name: String)
data class EmojiSection(val category: EmojiCategory, val emojis: List<EmojiData>)
fun parseEmojiData(lines: Sequence<String>): List<EmojiSection> {
val sections = mutableListOf<EmojiSection>()
var emojis = mutableListOf<EmojiData>()
for (line in lines) {
if (line.isEmpty()) continue
if (line.startsWith(EMOJI_GROUP_PREFIX)) {
val group = line.removePrefix(EMOJI_GROUP_PREFIX)
val category = EmojiCategory.entries.firstOrNull { it.unicodeGroup == group }
?: throw IllegalArgumentException("unknown emoji group: $group")
emojis = mutableListOf()
sections.add(EmojiSection(category, emojis))
} else {
val (emoji, name) = line.split("\t", limit = 2)
emojis.add(EmojiData(emoji, name))
}
}
return sections
}
val emojiSections: List<EmojiSection> by lazy {
val stream = EmojiCategory::class.java.getResourceAsStream(EMOJI_DATA_RESOURCE)
?: throw IllegalStateException("missing resource $EMOJI_DATA_RESOURCE")
stream.bufferedReader().useLines(::parseEmojiData)
}
fun searchEmoji(sections: List<EmojiSection>, query: String): List<EmojiData> {
val q = query.trim()
return sections.flatMap { s -> s.emojis.filter { it.name.contains(q, ignoreCase = true) } }
}
@Composable
actual fun ComposeEmojiButton(pickerShown: MutableState<Boolean>, enabled: Boolean, onEmoji: (String) -> Unit) {
// the popup opens above the button, so in a short window it must not be taller than the space there
var spaceAbove by remember { mutableStateOf(EMOJI_POPUP_HEIGHT) }
val density = LocalDensity.current
Box(Modifier.padding(start = EMOJI_BUTTON_START_PADDING).onGloballyPositioned { spaceAbove = with(density) { it.positionInWindow().y.toDp() } }) {
EmojiIconButton(painterResource(MR.images.ic_add_reaction), stringResource(MR.strings.icon_descr_emoji), enabled) {
pickerShown.value = !pickerShown.value
}
if (pickerShown.value) {
Popup(
popupPositionProvider = AboveAnchorPositionProvider,
onDismissRequest = { pickerShown.value = false },
properties = PopupProperties(focusable = true),
// closing on KeyUp, as the window does, so that the same key release does not also close the top modal
onKeyEvent = {
if (it.key == Key.Escape) {
if (it.type == KeyEventType.KeyUp) pickerShown.value = false
true
} else false
}
) {
EmojiPicker(height = minOf(EMOJI_POPUP_HEIGHT, spaceAbove), onEmoji = onEmoji)
}
}
}
}
private object AboveAnchorPositionProvider : PopupPositionProvider {
override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset {
val x = if (layoutDirection == LayoutDirection.Ltr) anchorBounds.left else anchorBounds.right - popupContentSize.width
return IntOffset(
x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)),
(anchorBounds.top - popupContentSize.height).coerceAtLeast(0)
)
}
}
@Composable
private fun EmojiPicker(height: Dp, onEmoji: (String) -> Unit) {
val sections = remember { emojiSections }
val query = remember { mutableStateOf("") }
val found = remember { derivedStateOf { if (query.value.isBlank()) null else searchEmoji(sections, query.value) } }
val gridState = rememberLazyGridState()
KeyChangeEffect(found.value) { gridState.scrollToItem(0) }
val headerIndexes = remember(sections) {
var index = 0
sections.map { s -> index.also { index += s.emojis.size + 1 } }
}
Surface(
Modifier.size(EMOJI_POPUP_WIDTH, height),
shape = RoundedCornerShape(12.dp),
elevation = 8.dp,
border = BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.2f))
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF).size(22.dp),
tint = MaterialTheme.colors.secondary
)
SearchTextField(Modifier.weight(1f), alwaysVisible = false) { query.value = it }
}
if (found.value == null) {
EmojiCategoryTabs(sections, headerIndexes, gridState)
}
Divider()
LazyVerticalGrid(
columns = GridCells.Adaptive(EMOJI_CELL_SIZE),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(6.dp)
) {
val foundEmojis = found.value
if (foundEmojis == null) {
sections.forEach { s ->
sectionHeader(s.category.title)
items(s.emojis) { EmojiCell(it.emoji, onEmoji) }
}
} else if (foundEmojis.isEmpty()) {
sectionHeader(MR.strings.no_emoji_found)
} else {
items(foundEmojis) { EmojiCell(it.emoji, onEmoji) }
}
}
}
}
}
@Composable
private fun EmojiCategoryTabs(sections: List<EmojiSection>, headerIndexes: List<Int>, gridState: LazyGridState) {
val scope = rememberCoroutineScope()
val selected = remember {
derivedStateOf { headerIndexes.indexOfLast { it <= gridState.firstVisibleItemIndex } }
}
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) {
sections.forEachIndexed { i, s ->
EmojiTab(selected = selected.value == i, onClick = { scope.launch { gridState.scrollToItem(headerIndexes[i]) } }) {
Text(s.category.icon, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_TAB_ICON_SIZE.toSp() })
}
}
}
}
@Composable
private fun EmojiTab(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
Box(
Modifier
.size(EMOJI_TAB_SIZE)
.clip(RoundedCornerShape(8.dp))
.background(if (selected) MaterialTheme.colors.secondary.copy(alpha = 0.15f) else Color.Transparent)
.focusProperties { canFocus = false }
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
content()
}
}
private fun LazyGridScope.sectionHeader(title: StringResource) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
stringResource(title),
Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary
)
}
}
@Composable
private fun EmojiCell(emoji: String, onEmoji: (String) -> Unit) {
Box(
Modifier
.size(EMOJI_CELL_SIZE)
.clip(RoundedCornerShape(8.dp))
// not focusable, so that the search field keeps focus and typing after a click goes to search
.focusProperties { canFocus = false }
.clickable { onEmoji(emoji) },
contentAlignment = Alignment.Center
) {
Text(emoji, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_SIZE.toSp() }, maxLines = 1)
}
}
@Composable
private fun EmojiIconButton(icon: Painter, contentDescription: String, enabled: Boolean, onClick: () -> Unit) {
IconButton(onClick, enabled = enabled) {
Icon(
icon,
contentDescription = contentDescription,
tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,55 @@
package chat.simplex.app
import chat.simplex.common.views.chat.*
import kotlin.test.*
// use this command for testing:
// ./gradlew desktopTest
class EmojiPickerDataTest {
private val skinToneModifiers = 0x1F3FB..0x1F3FF
@Test
fun testBundledDataHasAllCategoriesInOrder() {
assertEquals(EmojiCategory.entries.toList(), emojiSections.map { it.category })
emojiSections.forEach { assertTrue(it.emojis.isNotEmpty(), "empty category ${it.category}") }
assertEquals(1870, emojiSections.sumOf { it.emojis.size })
}
@Test
fun testBundledDataEntries() {
val all = emojiSections.flatMap { it.emojis }
assertEquals(all.size, all.map { it.emoji }.toSet().size, "duplicate emoji")
all.forEach {
assertTrue(it.emoji.isNotEmpty() && it.name.isNotBlank(), "bad entry $it")
assertFalse(it.emoji.codePoints().anyMatch { cp -> cp in skinToneModifiers }, "skin tone variant ${it.name}")
}
assertEquals(EmojiData("😀", "grinning face"), emojiSections.first().emojis.first())
assertEquals(EmojiData("🏴󠁧󠁢󠁷󠁬󠁳󠁿", "flag: Wales"), emojiSections.last().emojis.last())
}
@Test
fun testParseEmojiData() {
val sections = parseEmojiData(sequenceOf("@Smileys & Emotion", "😀\tgrinning face", "", "@Flags", "🏁\tchequered flag", "#️⃣\tkeycap: #"))
assertEquals(
listOf(
EmojiSection(EmojiCategory.Smileys, listOf(EmojiData("😀", "grinning face"))),
EmojiSection(EmojiCategory.Flags, listOf(EmojiData("🏁", "chequered flag"), EmojiData("#️⃣", "keycap: #"))),
),
sections
)
}
@Test
fun testParseUnknownGroupFails() {
assertFailsWith<IllegalArgumentException> { parseEmojiData(sequenceOf("@Component", "🏻\tlight skin tone")) }
}
@Test
fun testSearchEmoji() {
assertEquals(listOf("❤️"), searchEmoji(emojiSections, "red heart").map { it.emoji })
assertEquals(searchEmoji(emojiSections, "red heart"), searchEmoji(emojiSections, " RED Heart "))
val flags = searchEmoji(emojiSections, "flag: ")
assertTrue(flags.size > 250, "found ${flags.size} flags")
assertTrue(searchEmoji(emojiSections, "no such emoji name").isEmpty())
}
}