for-android/app/src/main/java/chat/revolt/internals/EmojiImpl.kt

276 lines
10 KiB
Kotlin

package chat.revolt.internals
import android.content.Context
import chat.revolt.R
import chat.revolt.RevoltApplication
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltJson
import chat.revolt.api.schemas.Server
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
@Serializable
data class Emoji(
val base: List<Long>,
val alternates: List<List<Long>>,
val emoticons: List<String>,
val shortcodes: List<String>,
val animated: Boolean
)
@Serializable
data class EmojiGroup(
val group: String,
val emoji: List<Emoji>
)
enum class FitzpatrickSkinTone(val modifierCodepoint: Int?) {
None(null),
Light(0x1F3FB),
MediumLight(0x1F3FC),
Medium(0x1F3FD),
MediumDark(0x1F3FE),
Dark(0x1F3FF)
}
enum class UnicodeEmojiSection(val googleName: String, val nameResource: Int) {
Smileys("Smileys and emotions", R.string.emoji_category_smileys),
People("People", R.string.emoji_category_people),
Animals("Animals and nature", R.string.emoji_category_animals),
Food("Food and drink", R.string.emoji_category_food),
Travel("Travel and places", R.string.emoji_category_travel),
Activities("Activities and events", R.string.emoji_category_activities),
Objects("Objects", R.string.emoji_category_objects),
Symbols("Symbols", R.string.emoji_category_symbols),
Flags("Flags", R.string.emoji_category_flags)
}
sealed class Category {
data class UnicodeEmojiCategory(val definition: UnicodeEmojiSection) : Category()
data class ServerEmoteCategory(val server: Server) : Category()
}
sealed class EmojiPickerItem {
data class Section(val category: Category) : EmojiPickerItem()
data class UnicodeEmoji(
val character: String,
val hasSkinTones: Boolean,
val alternates: List<List<Long>>
) : EmojiPickerItem()
data class ServerEmote(val emote: chat.revolt.api.schemas.Emoji) : EmojiPickerItem()
}
class EmojiImpl {
private var metadata: List<EmojiGroup>
private fun initMetadata(context: Context): List<EmojiGroup> {
val json = context.assets.open("metadata/emoji.json").use {
it.reader().readText()
}
return RevoltJson.decodeFromString(ListSerializer(EmojiGroup.serializer()), json)
}
fun serversWithEmotes(): List<Server> {
return RevoltAPI
.emojiCache
.values
.asSequence()
.map { it.parent }
.filterNotNull()
.filter { it.type == "Server" }
.map { it.id }
.distinct()
.mapNotNull { RevoltAPI.serverCache[it] }
.toList()
}
fun serverEmoteList(server: Server): List<EmojiPickerItem> {
val list = mutableListOf<EmojiPickerItem>()
val emotes = RevoltAPI.emojiCache.values.filter { it.parent?.id == server.id }
list.add(EmojiPickerItem.Section(Category.ServerEmoteCategory(server)))
list.addAll(emotes.map { EmojiPickerItem.ServerEmote(it) })
return list
}
fun flatPickerList(): List<EmojiPickerItem> {
val list = mutableListOf<EmojiPickerItem>()
for (server in serversWithEmotes()) {
list.addAll(serverEmoteList(server))
}
for (group in metadata) {
val category =
UnicodeEmojiSection.entries.find { it.googleName == group.group } ?: continue
list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category)))
list.addAll(
group.emoji.map { emoji ->
EmojiPickerItem.UnicodeEmoji(
emoji.base.joinToString("") { String(Character.toChars(it.toInt())) },
emoji.alternates.any { alternate ->
alternate.any { codepoint ->
codepoint in 0x1F3FB..0x1F3FF
}
},
emoji.alternates
)
}
)
}
return list
}
/**
* Returns a map of category to start and end index of the category in the flat picker list
* Impl
* ====
* 1. Iterate through servers that have emotes. Get the index of the server emote category.
* 2. Get all emotes in that server. Add the size of that list to the index of the server emote category.
* 3. Push Pair(index, index + size) to the map.
* 4. Iterate through all unicode emoji categories. Get the index of the category.
* Unless it's the last category {
* 5.1. Get the index of the next category. Subtract 1 from that index.
* 5.2. Push Pair(index, lastIndex) to the map.
* } Otherwise {
* 5. Push Pair(index, Int.MAX_VALUE) to the map.
* }
* 6. Return the map.
*/
fun categorySpans(flatPickerList: List<EmojiPickerItem>): Map<Category, Pair<Int, Int>> {
val output = mutableMapOf<Category, Pair<Int, Int>>()
for (server in serversWithEmotes()) {
val index =
flatPickerList.indexOfFirst {
it is EmojiPickerItem.Section && it.category is Category.ServerEmoteCategory && it.category.server == server
}
val allEmotesInThatServer =
RevoltAPI.emojiCache.values.filter { it.parent?.id == server.id }
val lastIndex = index + allEmotesInThatServer.size
output[Category.ServerEmoteCategory(server)] = Pair(index, lastIndex)
}
for (section in UnicodeEmojiSection.entries) {
val index =
flatPickerList.indexOfFirst {
it is EmojiPickerItem.Section && it.category is Category.UnicodeEmojiCategory && it.category.definition == section
}
val lastIndex = if (section == UnicodeEmojiSection.entries.last()) {
Int.MAX_VALUE
} else {
val nextSection = UnicodeEmojiSection.entries[section.ordinal + 1]
flatPickerList.indexOfFirst {
it is EmojiPickerItem.Section && it.category is Category.UnicodeEmojiCategory && it.category.definition == nextSection
} - 1
}
output[Category.UnicodeEmojiCategory(section)] = Pair(index, lastIndex)
}
return output
}
/**
* All of our unicode emoji are the base variant with no modifiers applied by default.
* This function returns the unicode emoji with the modifier from the specified skin type applied.
*/
fun applyFitzpatrickSkinTone(
item: EmojiPickerItem.UnicodeEmoji,
skinType: FitzpatrickSkinTone
): String {
if (!item.hasSkinTones || skinType == FitzpatrickSkinTone.None) return item.character
// HACK: We simply find the modifier version from metadata that
// contains the skin tone modifier codepoint.
val modifier = item.alternates.maxByOrNull { alternate ->
// HACK HACK: We find the alternate with the most frequency of our skin tone modifier.
// This is because some emoji have multiple skin tone modifier and we are taking the
// easy way here by only allowing a single skin tone change. This is not ideal.
// Users are encouraged to use the system emoji keyboard to get the full range of
// skin tone modifiers.
alternate.count { it == skinType.modifierCodepoint?.toLong() }
}
return modifier?.joinToString("") { String(Character.toChars(it.toInt())) }
?: item.character
}
/**
* Perform a search on the flat picker list to find all custom and unicode emoji that match the
* query.
*/
fun searchForEmoji(query: String): List<EmojiPickerItem> {
val list = mutableListOf<EmojiPickerItem>()
for (server in serversWithEmotes()) {
val emotes = RevoltAPI.emojiCache.values.filter { it.parent?.id == server.id }
val matchingEmotes =
emotes.filter { it.name?.contains(query, ignoreCase = true) ?: false }
if (matchingEmotes.isNotEmpty()) {
list.add(EmojiPickerItem.Section(Category.ServerEmoteCategory(server)))
list.addAll(matchingEmotes.map { EmojiPickerItem.ServerEmote(it) })
}
}
for (group in metadata) {
val matchingEmoji = group.emoji.filter {
it.shortcodes.any { code ->
code.contains(
query,
ignoreCase = true
)
}
}
if (matchingEmoji.isNotEmpty()) {
val category =
UnicodeEmojiSection.entries.find { it.googleName == group.group } ?: continue
list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category)))
list.addAll(
matchingEmoji.map { emoji ->
EmojiPickerItem.UnicodeEmoji(
emoji.base.joinToString("") { String(Character.toChars(it.toInt())) },
emoji.alternates.any { alternate ->
alternate.any { codepoint ->
codepoint in 0x1F3FB..0x1F3FF
}
},
emoji.alternates
)
}
)
}
}
return list
}
fun unicodeByShortcode(shortcode: String): String? {
return metadata.asSequence().mapNotNull { group ->
group.emoji.find { emoji ->
emoji.shortcodes.any { code ->
code == ":${shortcode}:"
}
}
}.firstOrNull().let { emoji ->
emoji?.base?.joinToString("") { String(Character.toChars(it.toInt())) }
}
}
fun shortcodeContains(query: String): List<Emoji> {
return metadata.asSequence().map { group ->
group.emoji.filter { emoji ->
emoji.shortcodes.any { code ->
code.contains(query, ignoreCase = true)
}
}
}.flatten().toList()
}
init {
metadata = initMetadata(RevoltApplication.instance.applicationContext)
}
}