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
2026-10-16 14:34:04 +01:00
committed by GitHub
parent fba65d98ee
commit aaa388e570
15 changed files with 2429 additions and 7 deletions
@@ -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())
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ This document provides a structured mapping between product-level concepts, thei
| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt`, `GetStakeBanner.kt` | `Controller.hs` (`APIGetChats`) |
| PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` |
| PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) |
| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) |
| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt`, `ComposeEmojiPicker.kt` | `Controller.hs` (`APISendMessages`) |
| PC5 | Message Reactions | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/ChatItemView.kt` (ChatItemReactions composable) | `Controller.hs` (`APIChatItemReaction`) |
| PC6 | Message Editing | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `ChatItemInfoView.kt` | `Controller.hs` (`APIUpdateChatItem`) |
| PC7 | Message Deletion | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/MarkedDeletedItemView.kt`, `DeletedItemView.kt` | `Controller.hs` (`APIDeleteChatItem`) |
+27
View File
@@ -13,6 +13,7 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des
5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified)
6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented)
7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented)
8. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-08-desktop-emoji-picker-without-skin-tones-and-localized-search)
---
@@ -289,3 +290,29 @@ This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self
- GAP-03 (Database Passphrase Not Enforced) is compounded by this gap on Desktop.
- The `testCrypto()` function referenced in `AppCommon.desktop.kt:39` is commented out with a `// LALAL` marker, suggesting crypto testing was planned but never completed.
---
## GAP-08: Desktop: Emoji Picker Without Skin Tones and Localized Search
**Severity:** Low
**Category:** Feature / Platform
**Platform:** Desktop only
### Description
The desktop compose emoji picker lists emoji without skin tone variants, and its search matches English Unicode emoji names only.
### Affected Locations
- `common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt`
- `scripts/desktop/generate-emoji-data.py` (`../../scripts/desktop/` relative to `apps/multiplatform/`) -- drops skin tone variants
### Impact
Desktop users cannot pick a skin tone in the picker, and search in other languages finds nothing.
### Recommendation
1. Keep the skin tone variants in the generated data, grouped by base emoji, and add a skin tone choice to the picker.
2. Add localized names (CLDR annotations) to the generated data for search.
+2
View File
@@ -95,6 +95,7 @@ Bottom input area for composing messages:
|---|---|
| Text field | `PlatformTextField` with markdown support, `@mention` autocomplete, file paste support |
| Attachment button | Opens `ModalBottomSheetLayout` with options: camera, gallery (image/video), file |
| Emoji button | Desktop only (Android keyboards have their own emoji panel). Opens a popup with search and categories; a picked emoji is inserted at the cursor and the picker stays open. With text selected the emoji goes after it, as the text field drops its selection when the picker takes focus. Picked emoji are not remembered, for privacy. Esc, a click outside or the button closes it |
| Send button | Sends message; changes to checkmark for reports; animated size/alpha |
| Voice record button | Shown when text is empty and voice allowed; hold to record, release to preview |
| Live message button | Start/update live typing message (if `liveMessageAlertShown`) |
@@ -132,4 +133,5 @@ When `timedMessageAllowed` is true, compose bar includes a timer icon for settin
| `ChatView.kt` | `views/chat/ChatView.kt` |
| `ComposeView.kt` | `views/chat/ComposeView.kt` |
| `SendMsgView.kt` | `views/chat/SendMsgView.kt` |
| `ComposeEmojiPicker.kt` | `views/chat/ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) |
| Chat item views | `views/chat/item/*.kt` |
+29 -3
View File
@@ -14,6 +14,7 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView
6. [Attachment Handling](#6-attachment-handling)
7. [Draft Persistence](#7-draft-persistence)
8. [Source Files](#8-source-files)
9. [Emoji Picker](#9-emoji-picker)
---
@@ -41,9 +42,10 @@ ComposeView
| |-- ComposeImageView [media thumbnails]
| |-- ComposeVoiceView [voice recording waveform]
| +-- ComposeFileView [file name display]
|-- AttachmentAndCommandsButtons
|-- ComposeActionButtons
| |-- CommandsButton [bot commands "//"]
| +-- AttachmentButton [paperclip icon]
| |-- AttachmentButton [paperclip icon]
| +-- ComposeEmojiButton [desktop emoji picker popup]
+-- SendMsgView
|-- PlatformTextField [multiline text input]
|-- DeleteTextButton [clear text, shown on long text]
@@ -348,7 +350,7 @@ When `privacyLinkPreviews` is enabled and the message contains a URL:
## 7. Draft Persistence
**Location:** [`ComposeView.kt#L1230`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1230) (`KeyChangeEffect(chatModel.chatId.value)`)
**Location:** [`ComposeView.kt#L1394`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1394) (`KeyChangeEffect(chatModel.chatId.value)`)
Controlled by the `privacySaveLastDraft` preference.
@@ -397,3 +399,27 @@ When a draft exists for a chat, `ChatPreviewView` shows a pencil icon with the d
| `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions |
| `ComposeContextProfilePickerView.kt` | Profile picker in compose context |
| `SelectableChatItemToolbars.kt` | Multi-select mode toolbar (delete, forward, moderate) |
| `ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) | Desktop emoji button and picker, emoji insertion |
---
<a id="EmojiPicker"></a>
## 9. Emoji Picker
**Source:** [`ComposeEmojiPicker.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt), [`ComposeEmojiPicker.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt)
Desktop only; on Android the [`ComposeEmojiButton()`](../../common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt) actual is empty, as keyboards have their own emoji panel.
An emoji button in [`ComposeActionButtons()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1346-L1363) toggles `emojiPickerShown`, owned by `ComposeView`. A picked emoji goes through [`onEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1290-L1292): [`insertingEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt#L10-L14) replaces the selection (clamped to the text) and moves the cursor after the emoji, then `onMessageChange` applies markdown parsing, link sanitizing and the large-emoji font. Emoji are ignored while a message is in progress. The picker stays open after picking. The text field collapses its selection to the end when the popup takes focus, so the emoji goes after selected text.
[`emojiEnabled`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1282-L1288) is false when sending is not allowed or a voice message is previewed; the picker closes then. When the picker closes, the text field gets focus through `focusRequester`.
| Function | Behavior |
|---|---|
| [`ComposeEmojiButton()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L89-L115) | Button 4 dp after the attachment button (`EMOJI_BUTTON_START_PADDING`, against the row's -8 dp overlap). Focusable `Popup` above the button; closes on Esc (on key release, so the window does not also close its top modal), outside click or the button; its height is capped to the space above the button |
| [`EmojiPicker()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L127-L178) | Search field, category tabs, `LazyVerticalGrid` with one section per `EmojiCategory`; tabs and cells are not focusable, so typing after a click goes to search. Picked emoji are not stored, so nothing about what the user typed is kept in preferences |
Data: `desktopMain/resources/emoji/emoji.txt` is generated by `scripts/desktop/generate-emoji-data.py` from Unicode `emoji-test.txt` 15.0 (the version covered by the bundled NotoColorEmoji): fully-qualified emoji without skin tone variants, `@<group>` lines followed by `<emoji>\t<name>` lines. [`parseEmojiData()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L59-L76) maps groups to `EmojiCategory` and fails on an unknown group; [`emojiSections`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L78-L82) loads it lazily. [`searchEmoji()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L84-L87) matches the English name case-insensitively.
[GAP] No skin tone selection, and search uses English emoji names only.
+3
View File
@@ -111,6 +111,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features |
| `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages |
| `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle |
| `views/chat/ComposeEmojiPicker.kt` | PC4 | Low | Emoji button expect declaration, emoji insertion |
| `views/chat/ComposeVoiceView.kt` | PC9 | Medium | Voice message recording UI |
| `views/chat/ComposeFileView.kt` | PC10 | Low | File attachment preview in compose area |
| `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area |
@@ -365,6 +366,7 @@ Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/`
| `views/call/CallAudioDeviceManager.kt` | PC17 | Medium | Android audio routing (speaker, earpiece, bluetooth) |
| `views/chat/ComposeView.android.kt` | PC4, PC10 | Low | Android compose view extensions |
| `views/chat/SendMsgView.android.kt` | PC4 | Low | Android send button extensions |
| `views/chat/ComposeEmojiPicker.android.kt` | PC4 | Low | Empty emoji button actual (no picker on Android) |
| `views/chat/item/ChatItemView.android.kt` | PC2, PC3 | Low | Android chat item extensions |
| `views/chat/item/CIImageView.android.kt` | PC10 | Low | Android image rendering extensions |
| `views/chat/item/CIVideoView.android.kt` | PC10 | Low | Android video rendering extensions |
@@ -439,6 +441,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/`
| `views/call/CallView.desktop.kt` | PC17 | Medium | Desktop WebView-based WebRTC call |
| `views/chat/ComposeView.desktop.kt` | PC4, PC10 | Low | Desktop compose view (drag-and-drop, paste) |
| `views/chat/SendMsgView.desktop.kt` | PC4 | Low | Desktop send shortcut (Enter key handling) |
| `views/chat/ComposeEmojiPicker.desktop.kt` | PC4 | Low | Desktop emoji picker popup (data from `resources/emoji/emoji.txt`, search) |
| `views/chat/item/ChatItemView.desktop.kt` | PC2, PC3 | Low | Desktop chat item extensions |
| `views/chat/item/CIImageView.desktop.kt` | PC10 | Low | Desktop image rendering |
| `views/chat/item/CIVideoView.desktop.kt` | PC10 | Low | Desktop video rendering |
+51
View File
@@ -0,0 +1,51 @@
# Desktop: emoji picker in the compose field
## Problem
The desktop compose field has no way to choose an emoji: Linux has no system emoji picker, and on Windows and macOS the system pickers are shortcuts most users do not know. Android keyboards have their own emoji panel, so Android is not changed.
## Solution
On desktop, an emoji button next to the attachment button opens a popup emoji picker: search field, category tabs, grid of emoji. A picked emoji is inserted at the cursor and the cursor moves after it; the picker stays open, so several emoji can be picked in a row. Esc, a click outside or the button closes it, and focus returns to the text field. The text field collapses its selection to the end when the popup takes focus, so with text selected the emoji goes after it.
No protocol or core changes: picked emoji are ordinary message text.
## Technical design
### Common (`views/chat/ComposeEmojiPicker.kt`)
- `expect fun ComposeEmojiButton(pickerShown, enabled, onEmoji)`. It sits in `ComposeActionButtons` (renamed from `AttachmentAndCommandsButtons`) after the attachment button. The Android actual is empty.
- `fun ComposeMessage.insertingEmoji(emoji: String): ComposeMessage` replaces `selection` (clamped to the text) with the emoji and puts the cursor after it. `ComposeView.onEmoji` passes the result to `onMessageChange`, so markdown parsing, link sanitizing and the large-emoji font apply as for typed text. Ignored while a message is being sent.
`ComposeView` owns `emojiPickerShown` (`rememberSaveable`). It is closed when sending becomes disabled or a voice message is recorded (`emojiEnabled`). When it closes, focus returns to the text field through the existing `focusRequester`.
### Desktop (`ComposeEmojiPicker.desktop.kt`)
- Button: same look as the attachment button. The row keeps its `spacedBy(-8.dp)` overlap; the emoji button adds `EMOJI_BUTTON_START_PADDING` (4 dp), so its centre is 44 dp from the attachment button.
- Data: `desktopMain/resources/emoji/emoji.txt`, generated by `scripts/desktop/generate-emoji-data.py` from Unicode `emoji-test.txt` 15.0, the version covered by the bundled NotoColorEmoji 2.038 (all 1870 sequences checked against the font cmap). Fully-qualified emoji only, without the Component group and without skin tone variants. Format: `@<group>` lines, then `<emoji>\t<name>` lines. Parsed once, lazily (`emojiSections`).
- `EmojiCategory` maps Unicode group names to a tab emoji and a localized title.
- Popup: focusable `Popup` above the button (`AboveAnchorPositionProvider`), 360×420 dp, or shorter when the window has less space above the button. Search field (auto-focused), category tabs, then a `LazyVerticalGrid` with section headers; tabs and cells are not focusable, so typing after a click still goes to search. Esc (handled on key release, as the window does, so it does not also close the top modal), a click outside, or the button closes it.
- Search: case-insensitive substring of the English Unicode name (`searchEmoji`).
- No recently used list: storing picked emoji in preferences would keep a trace of what the user wrote outside the encrypted database.
- Rendering uses `EmojiFont` (Noto on Windows/Linux, system font on macOS), as emoji-only messages do.
## Affected docs
- `product/views/chat.md` — Compose Bar table: emoji button.
- `spec/client/compose.md` — emoji picker functions.
- `spec/impact.md` — new source files.
- `product/concepts.md` — PC4 source list.
## Not included
- Android emoji picker: the keyboard's emoji panel covers it. An in-app panel was built and tested in simulation, then dropped; it would also need its own recents disabled for privacy.
- Skin tone selection.
- Emoji shortcode autocomplete (`:smile:`).
- Localized emoji names for search.
- Custom (image) emoji.
## Verification
- Unit tests: `insertingEmoji` (empty, cursor, selection, surrogate pairs); desktop data loads, every group maps to a category, no skin tone modifiers, search.
- The real desktop app run under Xvfb with screenshots and a screen recording: button in a group compose bar and its spacing, insert at cursor, several picks, search, no results, category tabs, close by Esc / outside click / button with focus returned, emoji-only message in large font, light and dark themes, editing, live messages, chat switching, small window, large font, right-to-left layout.
- Android: `compileDebugKotlinAndroid` passes; the compose bar is unchanged from before.
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
# Generates the desktop emoji picker data from Unicode emoji-test.txt.
# Emoji version must not exceed the bundled NotoColorEmoji font (2.038 covers Emoji 15.0):
# curl -O https://unicode.org/Public/emoji/15.0/emoji-test.txt
# python3 scripts/desktop/generate-emoji-data.py emoji-test.txt apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt
# Output: "@<group>" lines followed by "<emoji>\t<name>" lines, in CLDR order.
import sys
SKIPPED_GROUPS = {"Component"}
SKIN_TONE_MODIFIERS = {0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF}
def parse(lines):
group = None
for line in lines:
line = line.rstrip("\n")
if line.startswith("# group: "):
group = line[len("# group: "):]
if group not in SKIPPED_GROUPS:
yield "@" + group
elif line and not line.startswith("#") and group not in SKIPPED_GROUPS:
codes, rest = line.split(";", 1)
status, comment = rest.split("#", 1)
if status.strip() != "fully-qualified":
continue
code_points = [int(c, 16) for c in codes.split()]
if SKIN_TONE_MODIFIERS.intersection(code_points):
continue
# comment is "<emoji> E<version> <name>"
name = comment.strip().split(" ", 2)[2]
yield "".join(map(chr, code_points)) + "\t" + name
def main():
src, dst = sys.argv[1], sys.argv[2]
with open(src, encoding="utf-8") as f:
out = list(parse(f))
with open(dst, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(out) + "\n")
if __name__ == "__main__":
main()