diff --git a/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt b/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt index ab5f62c8..8474543c 100644 --- a/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt +++ b/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt @@ -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) ) } } diff --git a/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt b/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt index a76f21c9..8639b919 100644 --- a/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt +++ b/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt @@ -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 ) diff --git a/app/src/main/java/chat/revolt/internals/EmojiMetadata.kt b/app/src/main/java/chat/revolt/internals/EmojiMetadata.kt index 67e539ff..bf557028 100644 --- a/app/src/main/java/chat/revolt/internals/EmojiMetadata.kt +++ b/app/src/main/java/chat/revolt/internals/EmojiMetadata.kt @@ -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, ) -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> { - val map = mutableMapOf>() + fun serversWithEmotes(): List { + 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 { + val list = mutableListOf() + 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 { val list = mutableListOf() + 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): Map> { - val map = mutableMapOf>() - var start = 0 - var end = 0 + fun categorySpans(flatPickerList: List): Map> { + val output = mutableMapOf>() - 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 { diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index db4cb18a..b7efdbfb 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -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()