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,201 +193,350 @@ fun NativeMessageField(
}
}
Row(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
verticalAlignment = Alignment.CenterVertically
Column(
modifier = modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
when {
editMode -> Icons.Default.Close
else -> Icons.Default.Add
},
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
contentDescription = stringResource(id = R.string.add_attachment_alt),
modifier = Modifier
.clip(CircleShape)
.size(32.dp)
.clickable {
when {
editMode -> cancelEdit()
else -> {
// hide keyboard because it's annoying
clearFocus()
onAddAttachment()
}
}
}
.padding(4.dp)
.testTag("add_attachment")
)
AndroidView(
factory = { context ->
object : androidx.appcompat.widget.AppCompatEditText(context) {
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
var ic = super.onCreateInputConnection(outAttrs)
EditorInfoCompat.setContentMimeTypes(
outAttrs,
arrayOf("image/*")
)
val mimeTypes = ViewCompat.getOnReceiveContentMimeTypes(this)
if (mimeTypes != null) {
EditorInfoCompat.setContentMimeTypes(outAttrs, mimeTypes)
ic = ic?.let { InputConnectionCompat.createWrapper(this, it, outAttrs) }
}
return ic
}
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd)
onSelectionChange(selStart to selEnd)
}
}.apply {
background = null
textSize = 16f
setPadding((density.density * 16.dp.value).toInt())
// Propagate text changes to parent
addTextChangedListener {
onValueChange(it.toString())
}
// Hide/show keyboard on focus change and propagate to parent
onFocusChangeListener = android.view.View.OnFocusChangeListener { _, hasFocus ->
val keyboard =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (hasFocus) {
keyboard.showSoftInput(
this,
0
)
} else {
keyboard.hideSoftInputFromWindow(this.windowToken, 0)
}
onFocusChange(hasFocus)
}
ViewCompat.setOnReceiveContentListener(
this,
arrayOf("image/*")
) { _, payload ->
// Check mimetype
if (payload.clip.description.hasMimeType("image/*")) {
// Get image
val item = payload.clip.getItemAt(0)
val uri = item.uri
if (uri == null) {
Log.e("MessageField", "Received payload with null uri")
return@setOnReceiveContentListener payload
}
onCommitAttachment(uri)
return@setOnReceiveContentListener null
}
payload
}
isFocusable = true
isFocusableInTouchMode = true
typeface = ResourcesCompat.getFont(context, R.font.inter)
// Set colours
highlightColor = selectionColour
setTextColor(contentColour)
setHintTextColor(ColorStateList.valueOf(placeholderColour))
// Caret colour and size
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val shapeDrawable = ShapeDrawable(RectShape())
shapeDrawable.paint.color = cursorColour
val sizeInDp = 1
val sizeInPixels = (sizeInDp * resources.displayMetrics.density).toInt()
shapeDrawable.intrinsicWidth = sizeInPixels
shapeDrawable.intrinsicHeight = sizeInPixels
setTextCursorDrawable(shapeDrawable)
}
clearFocus = {
this.clearFocus()
}
requestFocus = {
this.requestFocus()
}
}
},
update = {
if (value != it.text.toString()) {
it.setText(value)
it.setSelection(value.length)
}
it.hint = it.context.getString(placeholderResource, channelName)
},
modifier = Modifier
.weight(1f)
.testTag("message_field")
)
Icon(
painter = painterResource(R.drawable.ic_emoticon_24dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
contentDescription = stringResource(id = R.string.pick_emoji_alt),
modifier = Modifier
.clip(CircleShape)
.size(32.dp)
.clickable {
clearFocus()
onPickEmoji()
}
.padding(4.dp)
.testTag("pick_emoji")
)
Spacer(modifier = Modifier.width(8.dp))
AnimatedVisibility(
sendButtonVisible,
visible = autocompleteSuggestions.size > 0,
enter = expandIn(initialSize = { full ->
IntSize(
0,
full.height
full.width,
0
)
}) + slideInHorizontally(
}) + slideInVertically(
animationSpec = RevoltTweenInt,
initialOffsetX = { -it }
) + fadeIn(animationSpec = RevoltTweenFloat),
initialOffsetY = { -it }
),
exit = shrinkOut(targetSize = { full ->
IntSize(
0,
full.height
full.width,
0
)
}) + slideOutHorizontally(
}) + slideOutVertically(
animationSpec = RevoltTweenInt,
targetOffsetX = { it }
) + fadeOut(animationSpec = RevoltTweenFloat)
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)),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
when {
editMode -> Icons.Default.Edit
else -> Icons.Default.Send
editMode -> Icons.Default.Close
else -> Icons.Default.Add
},
tint = MaterialTheme.colorScheme.primary,
contentDescription = stringResource(id = R.string.send_alt),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
contentDescription = stringResource(id = R.string.add_attachment_alt),
modifier = Modifier
.padding(end = 8.dp)
.clip(CircleShape)
.clickable { onSendMessage() }
.size(32.dp)
.clickable {
when {
editMode -> cancelEdit()
else -> {
// hide keyboard because it's annoying
clearFocus()
onAddAttachment()
}
}
}
.padding(4.dp)
.testTag("send_message")
.testTag("add_attachment")
)
AndroidView(
factory = { context ->
object : androidx.appcompat.widget.AppCompatEditText(context) {
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
var ic = super.onCreateInputConnection(outAttrs)
EditorInfoCompat.setContentMimeTypes(
outAttrs,
arrayOf("image/*")
)
val mimeTypes = ViewCompat.getOnReceiveContentMimeTypes(this)
if (mimeTypes != null) {
EditorInfoCompat.setContentMimeTypes(outAttrs, mimeTypes)
ic = ic?.let {
InputConnectionCompat.createWrapper(
this,
it,
outAttrs
)
}
}
return ic
}
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
textSize = 16f
setPadding((density.density * 16.dp.value).toInt())
// Propagate text changes to parent
addTextChangedListener {
onValueChange(it.toString())
}
// Hide/show keyboard on focus change and propagate to parent
onFocusChangeListener =
android.view.View.OnFocusChangeListener { _, hasFocus ->
val keyboard =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (hasFocus) {
keyboard.showSoftInput(
this,
0
)
} else {
keyboard.hideSoftInputFromWindow(this.windowToken, 0)
}
onFocusChange(hasFocus)
}
ViewCompat.setOnReceiveContentListener(
this,
arrayOf("image/*")
) { _, payload ->
// Check mimetype
if (payload.clip.description.hasMimeType("image/*")) {
// Get image
val item = payload.clip.getItemAt(0)
val uri = item.uri
if (uri == null) {
Log.e("MessageField", "Received payload with null uri")
return@setOnReceiveContentListener payload
}
onCommitAttachment(uri)
return@setOnReceiveContentListener null
}
payload
}
isFocusable = true
isFocusableInTouchMode = true
typeface = ResourcesCompat.getFont(context, R.font.inter)
// Set colours
highlightColor = selectionColour
setTextColor(contentColour)
setHintTextColor(ColorStateList.valueOf(placeholderColour))
// Caret colour and size
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val shapeDrawable = ShapeDrawable(RectShape())
shapeDrawable.paint.color = cursorColour
val sizeInDp = 1
val sizeInPixels = (sizeInDp * resources.displayMetrics.density).toInt()
shapeDrawable.intrinsicWidth = sizeInPixels
shapeDrawable.intrinsicHeight = sizeInPixels
setTextCursorDrawable(shapeDrawable)
}
clearFocus = {
this.clearFocus()
}
requestFocus = {
this.requestFocus()
}
}
},
update = {
if (value != it.text.toString()) {
it.setText(value)
it.setSelection(value.length)
}
it.hint = it.context.getString(placeholderResource, channelName)
},
modifier = Modifier
.weight(1f)
.testTag("message_field")
)
Icon(
painter = painterResource(R.drawable.ic_emoticon_24dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
contentDescription = stringResource(id = R.string.pick_emoji_alt),
modifier = Modifier
.clip(CircleShape)
.size(32.dp)
.clickable {
clearFocus()
onPickEmoji()
}
.padding(4.dp)
.testTag("pick_emoji")
)
Spacer(modifier = Modifier.width(8.dp))
AnimatedVisibility(
sendButtonVisible,
enter = expandIn(initialSize = { full ->
IntSize(
0,
full.height
)
}) + slideInHorizontally(
animationSpec = RevoltTweenInt,
initialOffsetX = { -it }
) + fadeIn(animationSpec = RevoltTweenFloat),
exit = shrinkOut(targetSize = { full ->
IntSize(
0,
full.height
)
}) + slideOutHorizontally(
animationSpec = RevoltTweenInt,
targetOffsetX = { it }
) + fadeOut(animationSpec = RevoltTweenFloat)
) {
Icon(
when {
editMode -> Icons.Default.Edit
else -> Icons.Default.Send
},
tint = MaterialTheme.colorScheme.primary,
contentDescription = stringResource(id = R.string.send_alt),
modifier = Modifier
.padding(end = 8.dp)
.clip(CircleShape)
.clickable { onSendMessage() }
.size(32.dp)
.padding(4.dp)
.testTag("send_message")
)
}
}
}
}

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