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,201 +193,350 @@ fun NativeMessageField(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
||||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
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(
|
AnimatedVisibility(
|
||||||
sendButtonVisible,
|
visible = autocompleteSuggestions.size > 0,
|
||||||
enter = expandIn(initialSize = { full ->
|
enter = expandIn(initialSize = { full ->
|
||||||
IntSize(
|
IntSize(
|
||||||
0,
|
full.width,
|
||||||
full.height
|
0
|
||||||
)
|
)
|
||||||
}) + slideInHorizontally(
|
}) + slideInVertically(
|
||||||
animationSpec = RevoltTweenInt,
|
animationSpec = RevoltTweenInt,
|
||||||
initialOffsetX = { -it }
|
initialOffsetY = { -it }
|
||||||
) + fadeIn(animationSpec = RevoltTweenFloat),
|
),
|
||||||
exit = shrinkOut(targetSize = { full ->
|
exit = shrinkOut(targetSize = { full ->
|
||||||
IntSize(
|
IntSize(
|
||||||
0,
|
full.width,
|
||||||
full.height
|
0
|
||||||
)
|
)
|
||||||
}) + slideOutHorizontally(
|
}) + slideOutVertically(
|
||||||
animationSpec = RevoltTweenInt,
|
animationSpec = RevoltTweenInt,
|
||||||
targetOffsetX = { it }
|
targetOffsetY = { it }
|
||||||
) + fadeOut(animationSpec = RevoltTweenFloat)
|
)
|
||||||
) {
|
) {
|
||||||
|
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(
|
Icon(
|
||||||
when {
|
when {
|
||||||
editMode -> Icons.Default.Edit
|
editMode -> Icons.Default.Close
|
||||||
else -> Icons.Default.Send
|
else -> Icons.Default.Add
|
||||||
},
|
},
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
contentDescription = stringResource(id = R.string.send_alt),
|
contentDescription = stringResource(id = R.string.add_attachment_alt),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(end = 8.dp)
|
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable { onSendMessage() }
|
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
|
.clickable {
|
||||||
|
when {
|
||||||
|
editMode -> cancelEdit()
|
||||||
|
else -> {
|
||||||
|
// hide keyboard because it's annoying
|
||||||
|
clearFocus()
|
||||||
|
onAddAttachment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(4.dp)
|
.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")
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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