feat: emoji autocomplete

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-12-06 15:35:02 +01:00
parent 4e8b3746ac
commit 2d911f6e86
4 changed files with 442 additions and 178 deletions

View File

@ -9,7 +9,7 @@ object MessageProcessor {
private val ChannelRegex = Regex("(?:\\s|^)#(.+?)(?:\\s|\$)", RegexOption.IGNORE_CASE)
private val EmojiRegex = Regex(":(.+?):", RegexOption.IGNORE_CASE)
private val emojiMetadata = EmojiImpl()
val emoji = EmojiImpl()
/**
* Processes an outgoing message for sending.
@ -52,7 +52,7 @@ object MessageProcessor {
val emojiName = EmojiRegex.matchEntire(emoji)?.destructured?.component1()
?: return@fold acc
val byShortcode = emojiMetadata.unicodeByShortcode(emojiName)
val byShortcode = this.emoji.unicodeByShortcode(emojiName)
?: return@fold acc
acc.replace(":$emojiName:", byShortcode)

View File

@ -16,14 +16,22 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
@ -33,12 +41,17 @@ import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -62,7 +75,70 @@ import chat.revolt.R
import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.activities.RevoltTweenInt
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.Member
import chat.revolt.internals.Autocomplete
import kotlinx.coroutines.launch
fun String.applyAutocompleteSuggestion(
suggestion: AutocompleteSuggestion,
cursorPosition: Int
): String {
return when (suggestion) {
is AutocompleteSuggestion.User -> {
this.replaceRange(
cursorPosition - suggestion.query.length - 1,
cursorPosition,
"@${suggestion.user.username}#${suggestion.user.discriminator} "
)
}
is AutocompleteSuggestion.Channel -> {
if (suggestion.channel.name?.contains(" ") == true) {
this.replaceRange(
cursorPosition - suggestion.query.length - 1,
cursorPosition,
"#${suggestion.channel.name} "
)
} else {
this.replaceRange(
cursorPosition - suggestion.query.length - 1,
cursorPosition,
"<#${suggestion.channel.id}> "
)
}
}
is AutocompleteSuggestion.Emoji -> {
this.replaceRange(
cursorPosition - suggestion.query.length - 1,
cursorPosition,
suggestion.shortcode + " "
)
}
}
}
sealed class AutocompleteSuggestion {
data class User(
val user: chat.revolt.api.schemas.User,
val member: Member?,
val query: String
) : AutocompleteSuggestion()
data class Channel(
val channel: chat.revolt.api.schemas.Channel,
val query: String
) : AutocompleteSuggestion()
data class Emoji(
val shortcode: String,
val unicode: String?,
val custom: chat.revolt.api.schemas.Emoji?,
val query: String
) : AutocompleteSuggestion()
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NativeMessageField(
value: String,
@ -77,6 +153,8 @@ fun NativeMessageField(
forceSendButton: Boolean = false,
disabled: Boolean = false,
editMode: Boolean = false,
serverId: String? = null,
channelId: String? = null,
cancelEdit: () -> Unit = {},
onFocusChange: (Boolean) -> Unit = {},
onSelectionChange: (Pair<Int, Int>) -> Unit = {}
@ -96,11 +174,17 @@ fun NativeMessageField(
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val selectionColour = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f).toArgb()
val cursorColour = MaterialTheme.colorScheme.primary.toArgb()
val contentColour = LocalContentColor.current.toArgb()
val placeholderColour = LocalContentColor.current.copy(alpha = 0.5f).toArgb()
var selection by remember { mutableStateOf(0 to 0) }
val autocompleteSuggestions = remember { mutableStateListOf<AutocompleteSuggestion>() }
val autocompleteSuggestionScrollState = rememberScrollState()
LaunchedEffect(editMode) {
if (editMode) {
requestFocus()
@ -109,6 +193,127 @@ fun NativeMessageField(
}
}
Column(
modifier = modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
) {
AnimatedVisibility(
visible = autocompleteSuggestions.size > 0,
enter = expandIn(initialSize = { full ->
IntSize(
full.width,
0
)
}) + slideInVertically(
animationSpec = RevoltTweenInt,
initialOffsetY = { -it }
),
exit = shrinkOut(targetSize = { full ->
IntSize(
full.width,
0
)
}) + slideOutVertically(
animationSpec = RevoltTweenInt,
targetOffsetY = { it }
)
) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(autocompleteSuggestions.size, key = {
when (val item = autocompleteSuggestions[it]) {
is AutocompleteSuggestion.User -> item.user.id!!
is AutocompleteSuggestion.Channel -> item.channel.id!!
is AutocompleteSuggestion.Emoji -> item.shortcode
}
}) {
when (val item = autocompleteSuggestions[it]) {
is AutocompleteSuggestion.User -> {
SuggestionChip(
onClick = {
onValueChange(
value.applyAutocompleteSuggestion(
item,
selection.first
)
)
},
label = { Text("@${item.user.username}#${item.user.discriminator}") },
icon = {
Icon(
painter = painterResource(R.drawable.ic_human_greeting_variant_24dp),
contentDescription = null,
modifier = Modifier.size(SuggestionChipDefaults.IconSize)
)
},
modifier = Modifier
.animateItemPlacement()
)
}
is AutocompleteSuggestion.Channel -> {
SuggestionChip(
onClick = {
onValueChange(
value.applyAutocompleteSuggestion(
item,
selection.first
)
)
},
label = { Text("#${item.channel.name}") },
icon = {
Icon(
painter = painterResource(R.drawable.ic_pound_24dp),
contentDescription = null,
modifier = Modifier.size(SuggestionChipDefaults.IconSize)
)
},
modifier = Modifier
.animateItemPlacement()
)
}
is AutocompleteSuggestion.Emoji -> {
SuggestionChip(
onClick = {
onValueChange(
value.applyAutocompleteSuggestion(
item,
selection.first
)
)
},
label = { Text(item.shortcode) },
icon = {
if (item.unicode != null) {
Text(
item.unicode,
modifier = Modifier
.size(SuggestionChipDefaults.IconSize)
.align(Alignment.CenterHorizontally),
style = MaterialTheme.typography.bodyMedium
)
} else {
Icon(
painter = painterResource(R.drawable.ic_emoticon_24dp),
contentDescription = null,
modifier = Modifier.size(SuggestionChipDefaults.IconSize)
)
}
},
modifier = Modifier
.animateItemPlacement()
)
}
}
}
}
}
Row(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
@ -152,7 +357,13 @@ fun NativeMessageField(
val mimeTypes = ViewCompat.getOnReceiveContentMimeTypes(this)
if (mimeTypes != null) {
EditorInfoCompat.setContentMimeTypes(outAttrs, mimeTypes)
ic = ic?.let { InputConnectionCompat.createWrapper(this, it, outAttrs) }
ic = ic?.let {
InputConnectionCompat.createWrapper(
this,
it,
outAttrs
)
}
}
return ic
}
@ -160,6 +371,26 @@ fun NativeMessageField(
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd)
onSelectionChange(selStart to selEnd)
selection = selStart to selEnd
scope.launch {
autocompleteSuggestionScrollState.scrollTo(0)
}
autocompleteSuggestions.clear()
if (text?.isNotBlank() == false) return
if (selStart != selEnd) return
val lastWord =
text?.substring(0, selStart)?.split(" ")?.lastOrNull() ?: return
when {
lastWord.startsWith(':') && !lastWord.endsWith(':') -> {
autocompleteSuggestions.addAll(
Autocomplete.emoji(lastWord.substring(1))
)
}
}
}
}.apply {
background = null
@ -172,7 +403,8 @@ fun NativeMessageField(
}
// Hide/show keyboard on focus change and propagate to parent
onFocusChangeListener = android.view.View.OnFocusChangeListener { _, hasFocus ->
onFocusChangeListener =
android.view.View.OnFocusChangeListener { _, hasFocus ->
val keyboard =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (hasFocus) {
@ -307,6 +539,7 @@ fun NativeMessageField(
}
}
}
}
@Preview
@Composable

View File

@ -0,0 +1,21 @@
package chat.revolt.internals
import chat.revolt.components.chat.AutocompleteSuggestion
object Autocomplete {
private val emojiImpl = EmojiImpl()
fun emoji(query: String): List<AutocompleteSuggestion.Emoji> {
val unicodeResults = emojiImpl.shortcodeContains(query).map {
AutocompleteSuggestion.Emoji(
it.shortcodes.find { shortcode -> shortcode.contains(query) }
?: it.shortcodes.first(),
it.base.joinToString("") { s -> String(Character.toChars(s.toInt())) },
null,
query
)
}.distinctBy { it.shortcode }
return unicodeResults
}
}

View File

@ -259,6 +259,16 @@ class EmojiImpl {
}
}
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)
}