diff --git a/app/src/main/java/chat/revolt/api/internals/MessageProcessor.kt b/app/src/main/java/chat/revolt/api/internals/MessageProcessor.kt index 1e345b36..123a220a 100644 --- a/app/src/main/java/chat/revolt/api/internals/MessageProcessor.kt +++ b/app/src/main/java/chat/revolt/api/internals/MessageProcessor.kt @@ -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) diff --git a/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt b/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt index 8689c2ee..07c43bad 100644 --- a/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt +++ b/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt @@ -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) -> 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() } + 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") + ) + } } } } diff --git a/app/src/main/java/chat/revolt/internals/Autocomplete.kt b/app/src/main/java/chat/revolt/internals/Autocomplete.kt new file mode 100644 index 00000000..798fc681 --- /dev/null +++ b/app/src/main/java/chat/revolt/internals/Autocomplete.kt @@ -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 { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/internals/EmojiImpl.kt b/app/src/main/java/chat/revolt/internals/EmojiImpl.kt index 1903b009..5b0f0f51 100644 --- a/app/src/main/java/chat/revolt/internals/EmojiImpl.kt +++ b/app/src/main/java/chat/revolt/internals/EmojiImpl.kt @@ -259,6 +259,16 @@ class EmojiImpl { } } + fun shortcodeContains(query: String): List { + 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) }