Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions java/res/values/strings-emoji-visibility.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="emoji_settings_hidden_title">Hidden emojis</string>
<string name="emoji_settings_hidden_subtitle">Hide selected emojis from the emoji picker, search, recents, and emoji suggestions.</string>
<string name="emoji_settings_hidden_add">Emojis to hide</string>
<string name="emoji_settings_hidden_invalid">Enter supported emojis only</string>
<string name="emoji_settings_hidden_none">No emojis are hidden.</string>
<string name="emoji_settings_hidden_remove">Show this emoji again</string>
</resources>
4 changes: 3 additions & 1 deletion java/src/org/futo/inputmethod/latin/EmojiDictionary.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.futo.inputmethod.latin.settings.SettingsValuesForSuggestion
import org.futo.inputmethod.latin.uix.DataStoreHelper
import org.futo.inputmethod.latin.uix.SHOW_EMOJI_SUGGESTIONS
import org.futo.inputmethod.latin.uix.actions.PersistentEmojiState
import org.futo.inputmethod.latin.uix.actions.emoji.isEmojiHidden
import java.util.ArrayList
import java.util.Locale

Expand Down Expand Up @@ -38,6 +39,7 @@ class EmojiDictionary(locale: Locale) : Dictionary(TYPE_EMOJI, locale) {

if(emoji != null) {
emoji = PersistentEmojiState.transformEmojiToLastSkinTone(emoji)
if(isEmojiHidden(emoji)) emoji = null
}

return if(emoji != null) {
Expand Down Expand Up @@ -76,4 +78,4 @@ class EmojiDictionary(locale: Locale) : Dictionary(TYPE_EMOJI, locale) {
override fun isInDictionary(word: String?): Boolean {
return false
}
}
}
30 changes: 22 additions & 8 deletions java/src/org/futo/inputmethod/latin/uix/actions/EmojiAction.kt
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ import org.futo.inputmethod.latin.uix.PersistentActionState
import org.futo.inputmethod.latin.uix.SettingsKey
import org.futo.inputmethod.latin.uix.actions.emoji.EmojiItem
import org.futo.inputmethod.latin.uix.actions.emoji.EmojiView
import org.futo.inputmethod.latin.uix.actions.emoji.HIDDEN_EMOJIS
import org.futo.inputmethod.latin.uix.actions.emoji.isEmojiHidden
import org.futo.inputmethod.latin.uix.actions.emoji.normalizeHiddenEmojiSet
import org.futo.inputmethod.latin.uix.settings.pages.EmojiSettingsMenu
import org.futo.inputmethod.latin.uix.settings.useDataStore
import org.futo.inputmethod.latin.uix.settings.useDataStoreValue
import org.futo.inputmethod.latin.uix.theme.LocalCompatEmojiFamily
Expand Down Expand Up @@ -772,24 +776,33 @@ fun EmojiGrid(
searchFilter: String
) {
val context = LocalContext.current
val hiddenEmojis = useDataStoreValue(HIDDEN_EMOJIS)
val normalizedHiddenEmojis = remember(hiddenEmojis) {
normalizeHiddenEmojiSet(hiddenEmojis)
}
val visibleEmojis = remember(emojis, normalizedHiddenEmojis) {
emojis.filterNot { isEmojiHidden(it.emoji, normalizedHiddenEmojis) }
}

val emojiCategoryMap = remember {
emojis.associate { it.emoji to it.category }
val emojiCategoryMap = remember(visibleEmojis) {
visibleEmojis.associate { it.emoji to it.category }
}

val recentEmojis = remember {
runBlocking { context.getRecentEmojis() }.map {
EmojiItem(it, description = "", category = emojiCategoryMap[it] ?: "", skinTones = false)
}
val recentEmojis = remember(normalizedHiddenEmojis, emojiCategoryMap) {
runBlocking { context.getRecentEmojis() }
.filterNot { isEmojiHidden(it, normalizedHiddenEmojis) }
.map {
EmojiItem(it, description = "", category = emojiCategoryMap[it] ?: "", skinTones = false)
}
}

val skinTone = useDataStoreValue(LastUsedSkinTone)
val emojiTypeface = LocalCompatEmojiTypeface.current
val categorizedEmojis = remember(skinTone) {
val categorizedEmojis = remember(skinTone, visibleEmojis) {
var prevCategory = ""

buildList {
for(emoji in emojis) {
for(emoji in visibleEmojis) {
if (emoji.category != prevCategory) {
prevCategory = emoji.category
add(CategoryItem(emoji.category))
Expand Down Expand Up @@ -1151,6 +1164,7 @@ val EmojiAction = Action(
name = R.string.action_emoji_title,
canShowKeyboard = true,
simplePressImpl = null,
settingsMenu = EmojiSettingsMenu,
persistentState = { manager ->
val state = PersistentEmojiState()
manager.getLifecycleScope().launch {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.futo.inputmethod.latin.uix.actions.emoji

import androidx.datastore.preferences.core.stringSetPreferencesKey
import org.futo.inputmethod.latin.uix.DataStoreHelper
import org.futo.inputmethod.latin.uix.SettingsKey

val HIDDEN_EMOJIS = SettingsKey(
key = stringSetPreferencesKey("hiddenEmojis"),
default = emptySet()
)

private val emojiSkinToneModifiers = listOf(
"\ud83c\udffb",
"\ud83c\udffc",
"\ud83c\udffd",
"\ud83c\udffe",
"\ud83c\udfff",
)

/**
* Normalizes presentation and skin-tone variants so hiding one form hides the whole emoji family.
* ZWJ sequences and gender variants remain distinct.
*/
fun normalizeEmojiForVisibility(emoji: String): String {
var normalized = emoji.replace("\uFE0E", "").replace("\uFE0F", "")
emojiSkinToneModifiers.forEach { modifier ->
normalized = normalized.replace(modifier, "")
}
return normalized
}

fun parseEmojiSequenceForHiding(input: String, knownEmojis: Collection<String>): List<String>? {
val normalizedInput = normalizeEmojiForVisibility(input.trim())
if (normalizedInput.isEmpty()) return null

val candidates = knownEmojis
.mapNotNull { emoji ->
val normalized = normalizeEmojiForVisibility(emoji)
if (normalized.isEmpty()) null else emoji to normalized
}
.distinctBy { it.second }
.sortedByDescending { it.second.length }

val parsed = mutableListOf<String>()
var offset = 0
while (offset < normalizedInput.length) {
val match = candidates.firstOrNull { (_, normalized) ->
normalizedInput.startsWith(normalized, offset)
} ?: return null

parsed.add(match.first)
offset += match.second.length
}

return parsed
}

fun normalizeHiddenEmojiSet(hiddenEmojis: Set<String>): Set<String> =
hiddenEmojis.mapTo(mutableSetOf(), ::normalizeEmojiForVisibility)

fun isEmojiHidden(emoji: String, normalizedHiddenEmojis: Set<String>): Boolean =
normalizeEmojiForVisibility(emoji) in normalizedHiddenEmojis

fun isEmojiHidden(emoji: String): Boolean =
isEmojiHidden(
emoji,
normalizeHiddenEmojiSet(DataStoreHelper.getSetting(HIDDEN_EMOJIS))
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package org.futo.inputmethod.latin.uix.settings.pages

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.futo.inputmethod.latin.R
import org.futo.inputmethod.latin.uix.actions.PersistentEmojiState
import org.futo.inputmethod.latin.uix.actions.emoji.HIDDEN_EMOJIS
import org.futo.inputmethod.latin.uix.actions.emoji.parseEmojiSequenceForHiding
import org.futo.inputmethod.latin.uix.settings.Tip
import org.futo.inputmethod.latin.uix.settings.UserSetting
import org.futo.inputmethod.latin.uix.settings.UserSettingsMenu
import org.futo.inputmethod.latin.uix.settings.useDataStore

@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun HiddenEmojiEditor() {
val context = LocalContext.current
val knownEmojis = PersistentEmojiState.emojis.value
LaunchedEffect(Unit) {
if (PersistentEmojiState.emojis.value == null) {
PersistentEmojiState.loadEmojis(context)
}
}

val (hiddenEmojis, setHiddenEmojis) = useDataStore(
key = HIDDEN_EMOJIS.key,
default = HIDDEN_EMOJIS.default
)
var newEmoji by remember { mutableStateOf("") }
var invalidInput by remember { mutableStateOf(false) }

Row(modifier = Modifier.padding(16.dp, 16.dp, 0.dp, 0.dp)) {
TextField(
value = newEmoji,
onValueChange = {
newEmoji = it
invalidInput = false
},
modifier = Modifier.weight(1.0f),
label = { Text(stringResource(R.string.emoji_settings_hidden_add)) },
isError = invalidInput,
supportingText = if (invalidInput) {
{ Text(stringResource(R.string.emoji_settings_hidden_invalid)) }
} else {
null
}
)
IconButton(
enabled = newEmoji.isNotBlank() && knownEmojis != null,
onClick = {
val parsed = knownEmojis?.let {
parseEmojiSequenceForHiding(newEmoji, it.map { emoji -> emoji.emoji })
}

if (parsed == null) {
invalidInput = true
} else {
setHiddenEmojis(hiddenEmojis + parsed)
newEmoji = ""
invalidInput = false
}
},
modifier = Modifier.align(Alignment.CenterVertically)
) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(R.string.emoji_settings_hidden_add)
)
}
}

if (hiddenEmojis.isEmpty()) {
Tip(stringResource(R.string.emoji_settings_hidden_none))
} else {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
hiddenEmojis.sorted().forEach { emoji ->
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = emoji,
modifier = Modifier.padding(start = 12.dp, end = 2.dp)
)
IconButton(
onClick = { setHiddenEmojis(hiddenEmojis - emoji) },
modifier = Modifier.size(40.dp)
) {
Icon(
Icons.Default.Clear,
contentDescription = stringResource(R.string.emoji_settings_hidden_remove),
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
}
}

val EmojiSettingsMenu = UserSettingsMenu(
title = R.string.action_emoji_title,
navPath = "actions/emoji",
registerNavPath = true,
settings = listOf(
UserSetting(
name = R.string.emoji_settings_hidden_title,
subtitle = R.string.emoji_settings_hidden_subtitle
) {
HiddenEmojiEditor()
}
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package org.futo.inputmethod.latin.uix.actions.emoji

import androidx.test.filters.SmallTest
import androidx.test.runner.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class EmojiVisibilityTests {
@Test
fun presentationSelectorsNormalizeToSameEmoji() {
assertEquals(
normalizeEmojiForVisibility("❤"),
normalizeEmojiForVisibility("❤️")
)
}

@Test
fun skinToneVariantsNormalizeToSameEmoji() {
assertEquals(
normalizeEmojiForVisibility("👍"),
normalizeEmojiForVisibility("👍🏽")
)
}

@Test
fun hiddenEmojiMatchesPresentationAndSkinToneVariants() {
val hidden = normalizeHiddenEmojiSet(setOf("👍", "❤️"))

assertTrue(isEmojiHidden("👍🏿", hidden))
assertTrue(isEmojiHidden("❤", hidden))
assertFalse(isEmojiHidden("👎", hidden))
}

@Test
fun distinctZwjSequencesStayDistinct() {
assertNotEquals(
normalizeEmojiForVisibility("👩‍⚕️"),
normalizeEmojiForVisibility("👨‍⚕️")
)
}

@Test
fun adjacentEmojiInputSplitsIntoCatalogEntries() {
val known = listOf("😀", "😃", "👍", "❤️")

assertEquals(
listOf("😀", "😃", "👍", "❤️"),
parseEmojiSequenceForHiding("😀😃👍🏽❤️", known)
)
}

@Test
fun parserPrefersWholeZwjSequence() {
val known = listOf("👩", "⚕️", "👩‍⚕️")

assertEquals(
listOf("👩‍⚕️"),
parseEmojiSequenceForHiding("👩‍⚕️", known)
)
}

@Test
fun parserKeepsFlagSequencesTogether() {
val known = listOf("🇺🇸", "🇨🇦")

assertEquals(
listOf("🇺🇸", "🇨🇦"),
parseEmojiSequenceForHiding("🇺🇸🇨🇦", known)
)
}

@Test
fun parserRejectsUnmatchedText() {
assertNull(
parseEmojiSequenceForHiding("😀hello😃", listOf("😀", "😃"))
)
}
}