feat: emoji autocomplete
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
4e8b3746ac
commit
2d911f6e86
|
|
@ -9,7 +9,7 @@ object MessageProcessor {
|
||||||
private val ChannelRegex = Regex("(?:\\s|^)#(.+?)(?:\\s|\$)", RegexOption.IGNORE_CASE)
|
private val ChannelRegex = Regex("(?:\\s|^)#(.+?)(?:\\s|\$)", RegexOption.IGNORE_CASE)
|
||||||
private val EmojiRegex = Regex(":(.+?):", RegexOption.IGNORE_CASE)
|
private val EmojiRegex = Regex(":(.+?):", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
private val emojiMetadata = EmojiImpl()
|
val emoji = EmojiImpl()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes an outgoing message for sending.
|
* Processes an outgoing message for sending.
|
||||||
|
|
@ -52,7 +52,7 @@ object MessageProcessor {
|
||||||
val emojiName = EmojiRegex.matchEntire(emoji)?.destructured?.component1()
|
val emojiName = EmojiRegex.matchEntire(emoji)?.destructured?.component1()
|
||||||
?: return@fold acc
|
?: return@fold acc
|
||||||
|
|
||||||
val byShortcode = emojiMetadata.unicodeByShortcode(emojiName)
|
val byShortcode = this.emoji.unicodeByShortcode(emojiName)
|
||||||
?: return@fold acc
|
?: return@fold acc
|
||||||
|
|
||||||
acc.replace(":$emojiName:", byShortcode)
|
acc.replace(":$emojiName:", byShortcode)
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,22 @@ import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkOut
|
import androidx.compose.animation.shrinkOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.Icon
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.material3.surfaceColorAtElevation
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -62,7 +75,70 @@ import chat.revolt.R
|
||||||
import chat.revolt.activities.RevoltTweenFloat
|
import chat.revolt.activities.RevoltTweenFloat
|
||||||
import chat.revolt.activities.RevoltTweenInt
|
import chat.revolt.activities.RevoltTweenInt
|
||||||
import chat.revolt.api.schemas.ChannelType
|
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
|
@Composable
|
||||||
fun NativeMessageField(
|
fun NativeMessageField(
|
||||||
value: String,
|
value: String,
|
||||||
|
|
@ -77,6 +153,8 @@ fun NativeMessageField(
|
||||||
forceSendButton: Boolean = false,
|
forceSendButton: Boolean = false,
|
||||||
disabled: Boolean = false,
|
disabled: Boolean = false,
|
||||||
editMode: Boolean = false,
|
editMode: Boolean = false,
|
||||||
|
serverId: String? = null,
|
||||||
|
channelId: String? = null,
|
||||||
cancelEdit: () -> Unit = {},
|
cancelEdit: () -> Unit = {},
|
||||||
onFocusChange: (Boolean) -> Unit = {},
|
onFocusChange: (Boolean) -> Unit = {},
|
||||||
onSelectionChange: (Pair<Int, Int>) -> Unit = {}
|
onSelectionChange: (Pair<Int, Int>) -> Unit = {}
|
||||||
|
|
@ -96,11 +174,17 @@ fun NativeMessageField(
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val selectionColour = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f).toArgb()
|
val selectionColour = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f).toArgb()
|
||||||
val cursorColour = MaterialTheme.colorScheme.primary.toArgb()
|
val cursorColour = MaterialTheme.colorScheme.primary.toArgb()
|
||||||
val contentColour = LocalContentColor.current.toArgb()
|
val contentColour = LocalContentColor.current.toArgb()
|
||||||
val placeholderColour = LocalContentColor.current.copy(alpha = 0.5f).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) {
|
LaunchedEffect(editMode) {
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
requestFocus()
|
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(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
||||||
|
|
@ -152,7 +357,13 @@ fun NativeMessageField(
|
||||||
val mimeTypes = ViewCompat.getOnReceiveContentMimeTypes(this)
|
val mimeTypes = ViewCompat.getOnReceiveContentMimeTypes(this)
|
||||||
if (mimeTypes != null) {
|
if (mimeTypes != null) {
|
||||||
EditorInfoCompat.setContentMimeTypes(outAttrs, mimeTypes)
|
EditorInfoCompat.setContentMimeTypes(outAttrs, mimeTypes)
|
||||||
ic = ic?.let { InputConnectionCompat.createWrapper(this, it, outAttrs) }
|
ic = ic?.let {
|
||||||
|
InputConnectionCompat.createWrapper(
|
||||||
|
this,
|
||||||
|
it,
|
||||||
|
outAttrs
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ic
|
return ic
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +371,26 @@ fun NativeMessageField(
|
||||||
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
||||||
super.onSelectionChanged(selStart, selEnd)
|
super.onSelectionChanged(selStart, selEnd)
|
||||||
onSelectionChange(selStart to 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 {
|
}.apply {
|
||||||
background = null
|
background = null
|
||||||
|
|
@ -172,7 +403,8 @@ fun NativeMessageField(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide/show keyboard on focus change and propagate to parent
|
// Hide/show keyboard on focus change and propagate to parent
|
||||||
onFocusChangeListener = android.view.View.OnFocusChangeListener { _, hasFocus ->
|
onFocusChangeListener =
|
||||||
|
android.view.View.OnFocusChangeListener { _, hasFocus ->
|
||||||
val keyboard =
|
val keyboard =
|
||||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
if (hasFocus) {
|
if (hasFocus) {
|
||||||
|
|
@ -306,6 +538,7 @@ fun NativeMessageField(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
init {
|
||||||
metadata = initMetadata(RevoltApplication.instance.applicationContext)
|
metadata = initMetadata(RevoltApplication.instance.applicationContext)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue