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 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
metadata = initMetadata(RevoltApplication.instance.applicationContext)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue