mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 21:52:28 +00:00
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:
15 files changed
+2429
-7
No files matched your search
+7
@@ -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) {}
|
||||
+14
@@ -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))
|
||||
}
|
||||
+16
-3
@@ -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 & emotion</string>
|
||||
<string name="emoji_category_people">People & body</string>
|
||||
<string name="emoji_category_animals">Animals & nature</string>
|
||||
<string name="emoji_category_food">Food & drink</string>
|
||||
<string name="emoji_category_travel">Travel & 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("😀"))
|
||||
}
|
||||
}
|
||||
+248
@@ -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
+55
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user