feat: server custom emoji into emote picker

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-10-15 01:45:05 +02:00
parent 6736a2677d
commit 5f82548d1e
4 changed files with 246 additions and 68 deletions

View File

@ -1,19 +1,25 @@
package chat.revolt.components.emoji
import android.util.TypedValue
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
@ -21,23 +27,32 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R
import chat.revolt.internals.EmojiCategory
import chat.revolt.api.REVOLT_FILES
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.generic.IconPlaceholder
import chat.revolt.components.generic.RemoteImage
import chat.revolt.internals.Category
import chat.revolt.internals.EmojiMetadata
import chat.revolt.internals.EmojiPickerItem
import chat.revolt.internals.UnicodeEmojiSection
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EmojiPicker(
onEmojiSelected: (String) -> Unit,
@ -45,35 +60,56 @@ fun EmojiPicker(
val view = LocalView.current
val metadata = remember { EmojiMetadata() }
val pickerList = remember(metadata) { metadata.flatPickerList() }
val servers = remember(metadata) { metadata.serversWithEmotes() }
val categorySpans = remember(pickerList) { metadata.categorySpans(pickerList) }
val gridState = rememberLazyGridState()
val categoryRowScrollState = rememberScrollState()
val scope = rememberCoroutineScope()
val spanCount = 9 // https://github.com/googlefonts/emoji-metadata/#readme
// The current category is the one that the user is currently looking at.
// We calculate this using the grid state and the category spans
// (which contain the start and end index of each category).
val currentCategory = remember {
val currentCategory = remember(gridState, categorySpans) {
derivedStateOf {
val firstVisibleItem = gridState.firstVisibleItemIndex
val firstVisible = gridState.firstVisibleItemIndex
val firstCategory =
categorySpans.entries.firstOrNull { it.value.first <= firstVisible && it.value.second >= firstVisible }?.key
for (category in categorySpans.keys) {
val (start, end) = categorySpans[category] ?: continue
if (firstVisibleItem + 1 in start..end) {
return@derivedStateOf category
}
}
return@derivedStateOf EmojiCategory.entries.last()
firstCategory
}
}
LaunchedEffect(currentCategory.value) {
// Scroll to the server icon of the current category.
val offset = categorySpans.entries.indexOfFirst { it.key == currentCategory.value }
var px = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
37f + 4f,
view.resources.displayMetrics
).toInt()
// If the user is looking at the unicode emoji, scroll to the end instead
// so that the category icons are all neatly aligned.
//
// Impl -> Not scrolling to "the end" but to the current category plus 50.
// (Which technically is an evil hack, but technically I could also
// poke an eye out with a spoon, so let's not worry about technicalities.)
if (currentCategory.value is Category.UnicodeEmojiCategory) px += 50
categoryRowScrollState.animateScrollTo(offset * px)
}
Column(
modifier = Modifier
.fillMaxSize()
) {
Row {
EmojiCategory.entries.forEach { category ->
Row(
modifier = Modifier
.horizontalScroll(categoryRowScrollState)
.height(37.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
servers.forEach { server ->
Column(
modifier = Modifier
.clip(CircleShape)
@ -81,36 +117,79 @@ fun EmojiPicker(
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
scope.launch {
val index =
pickerList.indexOfFirst { it is EmojiPickerItem.Category && it.category == category }
pickerList.indexOfFirst { it is EmojiPickerItem.Section && it.category is Category.ServerEmoteCategory && it.category.server == server }
gridState.animateScrollToItem(index)
}
}
.then(
if (currentCategory.value == category) {
if (currentCategory.value is Category.ServerEmoteCategory && (currentCategory.value as Category.ServerEmoteCategory).server == server) {
Modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
} else {
Modifier
}
)
.aspectRatio(1f)
.weight(1f),
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (server.icon == null) {
IconPlaceholder(
name = server.name ?: stringResource(R.string.unknown),
fontSize = 16.sp,
modifier = Modifier
.clip(CircleShape)
.fillMaxSize()
)
} else {
RemoteImage(
url = "$REVOLT_FILES/icons/${server.icon.id}/icon.gif?max_side=64",
description = server.name,
modifier = Modifier
.clip(CircleShape)
.fillMaxSize()
)
}
}
}
UnicodeEmojiSection.entries.forEach { category ->
Column(
modifier = Modifier
.clip(CircleShape)
.clickable {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
scope.launch {
val index =
pickerList.indexOfFirst { it is EmojiPickerItem.Section && it.category is Category.UnicodeEmojiCategory && it.category.definition == category }
gridState.animateScrollToItem(index)
}
}
.then(
if (currentCategory.value is Category.UnicodeEmojiCategory && (currentCategory.value as Category.UnicodeEmojiCategory).definition == category) {
Modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
} else {
Modifier
}
)
.aspectRatio(1f)
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
painter = when (category) {
EmojiCategory.Smileys -> painterResource(R.drawable.ic_emoticon_24dp)
EmojiCategory.People -> painterResource(R.drawable.ic_human_greeting_variant_24dp)
EmojiCategory.Animals -> painterResource(R.drawable.ic_snake_24dp)
EmojiCategory.Food -> painterResource(R.drawable.ic_glass_mug_variant_24dp)
EmojiCategory.Travel -> painterResource(R.drawable.ic_train_bus_24dp)
EmojiCategory.Activities -> painterResource(R.drawable.ic_skate_24dp)
EmojiCategory.Objects -> painterResource(R.drawable.ic_table_chair_24dp)
EmojiCategory.Symbols -> painterResource(R.drawable.ic_symbol_24dp)
EmojiCategory.Flags -> painterResource(R.drawable.ic_flag_24dp)
UnicodeEmojiSection.Smileys -> painterResource(R.drawable.ic_emoticon_24dp)
UnicodeEmojiSection.People -> painterResource(R.drawable.ic_human_greeting_variant_24dp)
UnicodeEmojiSection.Animals -> painterResource(R.drawable.ic_snake_24dp)
UnicodeEmojiSection.Food -> painterResource(R.drawable.ic_glass_mug_variant_24dp)
UnicodeEmojiSection.Travel -> painterResource(R.drawable.ic_train_bus_24dp)
UnicodeEmojiSection.Activities -> painterResource(R.drawable.ic_skate_24dp)
UnicodeEmojiSection.Objects -> painterResource(R.drawable.ic_table_chair_24dp)
UnicodeEmojiSection.Symbols -> painterResource(R.drawable.ic_symbol_24dp)
UnicodeEmojiSection.Flags -> painterResource(R.drawable.ic_flag_24dp)
},
contentDescription = null,
tint = if (currentCategory.value == category) {
tint = if (currentCategory.value is Category.UnicodeEmojiCategory && (currentCategory.value as Category.UnicodeEmojiCategory).definition == category) {
MaterialTheme.colorScheme.primary
} else LocalContentColor.current
)
@ -122,26 +201,27 @@ fun EmojiPicker(
columns = GridCells.Fixed(spanCount),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f)
modifier = Modifier.fillMaxSize()
) {
items(
pickerList.size,
span = {
val item = pickerList[it]
when (item) {
is EmojiPickerItem.Emoji -> GridItemSpan(1)
is EmojiPickerItem.Category -> GridItemSpan(spanCount)
is EmojiPickerItem.UnicodeEmoji -> GridItemSpan(1)
is EmojiPickerItem.ServerEmote -> GridItemSpan(1)
is EmojiPickerItem.Section -> GridItemSpan(spanCount)
}
}
) { index ->
when (val item = pickerList[index]) {
is EmojiPickerItem.Emoji -> {
is EmojiPickerItem.UnicodeEmoji -> {
Column(
modifier = Modifier
.clip(CircleShape)
.clickable {
onEmojiSelected(item.emoji)
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
onEmojiSelected(item.emoji)
}
.aspectRatio(1f)
.weight(1f),
@ -152,13 +232,55 @@ fun EmojiPicker(
}
}
is EmojiPickerItem.Category -> {
is EmojiPickerItem.ServerEmote -> {
Column(
modifier = Modifier
.clip(CircleShape)
.combinedClickable(
onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
onEmojiSelected(":${item.emote.id}:")
},
onLongClick = {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
scope.launch {
item.emote.id?.let {
ActionChannel.send(
Action.EmoteInfo(
it
)
)
}
}
}
)
.aspectRatio(1f)
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
RemoteImage(
url = "$REVOLT_FILES/emojis/${item.emote.id}/emoji.gif",
description = item.emote.name,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
)
}
}
is EmojiPickerItem.Section -> {
Text(
stringResource(item.category.nameResource),
when (item.category) {
is Category.UnicodeEmojiCategory -> stringResource(item.category.definition.nameResource)
is Category.ServerEmoteCategory -> item.category.server.name
?: stringResource(R.string.unknown)
},
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
.padding(8.dp)
)
}
}

View File

@ -11,23 +11,27 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val NoopHandler = {}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun IconPlaceholder(
name: String,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
onClick: () -> Unit = NoopHandler,
onLongClick: () -> Unit = NoopHandler,
fontSize: TextUnit = 20.sp
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp))
.then(
if (onClick != {} || onLongClick != {}) Modifier.combinedClickable(
if (onClick != NoopHandler || onLongClick != NoopHandler) Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
@ -36,7 +40,7 @@ fun IconPlaceholder(
) {
Text(
text = name.first().uppercase(),
fontSize = 20.sp,
fontSize = fontSize,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)

View File

@ -3,7 +3,9 @@ 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
@ -22,7 +24,7 @@ data class EmojiGroup(
val emoji: List<Emoji>,
)
enum class EmojiCategory(val googleName: String, val nameResource: Int) {
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),
@ -34,9 +36,15 @@ enum class EmojiCategory(val googleName: String, val nameResource: Int) {
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 Emoji(val emoji: String) : EmojiPickerItem()
data class Category(val category: EmojiCategory) : EmojiPickerItem()
data class Section(val category: Category) : EmojiPickerItem()
data class UnicodeEmoji(val emoji: String) : EmojiPickerItem()
data class ServerEmote(val emote: chat.revolt.api.schemas.Emoji) : EmojiPickerItem()
}
class EmojiMetadata {
@ -49,25 +57,43 @@ class EmojiMetadata {
return RevoltJson.decodeFromString(ListSerializer(EmojiGroup.serializer()), json)
}
fun pickerList(): Map<EmojiCategory, List<Emoji>> {
val map = mutableMapOf<EmojiCategory, List<Emoji>>()
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()
}
for (group in metadata) {
val category = EmojiCategory.entries.find { it.name == group.group } ?: continue
map[category] = group.emoji
}
fun serverEmoteList(server: Server): List<EmojiPickerItem> {
val list = mutableListOf<EmojiPickerItem>()
val emotes = RevoltAPI.emojiCache.values.filter { it.parent?.id == server.id }
return map
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 = EmojiCategory.entries.find { it.googleName == group.group } ?: continue
list.add(EmojiPickerItem.Category(category))
val category =
UnicodeEmojiSection.entries.find { it.googleName == group.group } ?: continue
list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category)))
list.addAll(group.emoji.map { emoji ->
EmojiPickerItem.Emoji(
EmojiPickerItem.UnicodeEmoji(
emoji.base.joinToString("") { String(Character.toChars(it.toInt())) }
)
})
@ -77,25 +103,46 @@ class EmojiMetadata {
}
/**
* Returns a map of category to start and end index of the category in the flat picker 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<EmojiCategory, Pair<Int, Int>> {
val map = mutableMapOf<EmojiCategory, Pair<Int, Int>>()
var start = 0
var end = 0
fun categorySpans(flatPickerList: List<EmojiPickerItem>): Map<Category, Pair<Int, Int>> {
val output = mutableMapOf<Category, Pair<Int, Int>>()
for (item in flatPickerList) {
if (item is EmojiPickerItem.Category) {
if (start != end) {
map[flatPickerList[start].let { it as EmojiPickerItem.Category }.category] =
Pair(start, end)
}
start = end
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
}
end++
output[Category.UnicodeEmojiCategory(section)] = Pair(index, lastIndex)
}
return map
return output
}
init {

View File

@ -6,6 +6,7 @@ import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
@ -591,8 +592,12 @@ fun ChannelScreen(
)
}
}
AnimatedVisibility(visible = viewModel.currentBottomPane == BottomPane.EmojiPicker) {
BackHandler(enabled = viewModel.currentBottomPane == BottomPane.EmojiPicker) {
viewModel.currentBottomPane = BottomPane.None
}
Column(
modifier = Modifier
.fillMaxWidth()