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 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)

View File

@ -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

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 { init {
metadata = initMetadata(RevoltApplication.instance.applicationContext) metadata = initMetadata(RevoltApplication.instance.applicationContext)
} }