feat: compose-ify the message field
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
35c28bfde3
commit
e117bf4b74
|
|
@ -60,7 +60,7 @@ import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
|
||||||
import chat.revolt.api.schemas.ChannelType
|
import chat.revolt.api.schemas.ChannelType
|
||||||
import chat.revolt.api.settings.LoadedSettings
|
import chat.revolt.api.settings.LoadedSettings
|
||||||
import chat.revolt.api.settings.SyncedSettings
|
import chat.revolt.api.settings.SyncedSettings
|
||||||
import chat.revolt.components.chat.NativeMessageField
|
import chat.revolt.components.chat.MessageField
|
||||||
import chat.revolt.components.emoji.EmojiPicker
|
import chat.revolt.components.emoji.EmojiPicker
|
||||||
import chat.revolt.components.screens.chat.AttachmentManager
|
import chat.revolt.components.screens.chat.AttachmentManager
|
||||||
import chat.revolt.components.screens.chat.drawer.ChannelItem
|
import chat.revolt.components.screens.chat.drawer.ChannelItem
|
||||||
|
|
@ -382,8 +382,8 @@ fun ShareTargetScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
NativeMessageField(
|
MessageField(
|
||||||
value = viewModel.messageContent,
|
initialValue = "",
|
||||||
onValueChange = { viewModel.messageContent = it },
|
onValueChange = { viewModel.messageContent = it },
|
||||||
canAttach = false,
|
canAttach = false,
|
||||||
forceSendButton = viewModel.attachments.isNotEmpty(),
|
forceSendButton = viewModel.attachments.isNotEmpty(),
|
||||||
|
|
@ -404,7 +404,7 @@ fun ShareTargetScreen(
|
||||||
context.getString(R.string.share_target_select_channel),
|
context.getString(R.string.share_target_select_channel),
|
||||||
Toast.LENGTH_SHORT
|
Toast.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
return@NativeMessageField
|
return@MessageField
|
||||||
} else {
|
} else {
|
||||||
viewModel.send(selectedChannel!!) {
|
viewModel.send(selectedChannel!!) {
|
||||||
onFinished()
|
onFinished()
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,6 @@
|
||||||
package chat.revolt.components.chat
|
package chat.revolt.components.chat
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.ColorStateList
|
|
||||||
import android.graphics.drawable.ShapeDrawable
|
|
||||||
import android.graphics.drawable.shapes.RectShape
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.util.DisplayMetrics
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import android.view.inputmethod.EditorInfo
|
|
||||||
import android.view.inputmethod.InputConnection
|
|
||||||
import android.view.inputmethod.InputMethodManager
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandIn
|
import androidx.compose.animation.expandIn
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
|
|
@ -22,23 +11,36 @@ import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
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.content.ReceiveContentListener
|
||||||
|
import androidx.compose.foundation.content.consume
|
||||||
|
import androidx.compose.foundation.content.contentReceiver
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
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.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
|
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SuggestionChip
|
import androidx.compose.material3.SuggestionChip
|
||||||
import androidx.compose.material3.SuggestionChipDefaults
|
import androidx.compose.material3.SuggestionChipDefaults
|
||||||
|
|
@ -54,22 +56,31 @@ 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
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.isAltPressed
|
||||||
|
import androidx.compose.ui.input.key.isCtrlPressed
|
||||||
|
import androidx.compose.ui.input.key.isMetaPressed
|
||||||
|
import androidx.compose.ui.input.key.isShiftPressed
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.core.content.res.ResourcesCompat
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
|
||||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
|
||||||
import androidx.core.view.setPadding
|
|
||||||
import androidx.core.widget.addTextChangedListener
|
|
||||||
import chat.revolt.R
|
import chat.revolt.R
|
||||||
import chat.revolt.activities.RevoltTweenFloat
|
import chat.revolt.activities.RevoltTweenFloat
|
||||||
import chat.revolt.activities.RevoltTweenInt
|
import chat.revolt.activities.RevoltTweenInt
|
||||||
|
|
@ -81,48 +92,23 @@ import chat.revolt.components.generic.UserAvatar
|
||||||
import chat.revolt.components.screens.chat.ChannelIcon
|
import chat.revolt.components.screens.chat.ChannelIcon
|
||||||
import chat.revolt.internals.Autocomplete
|
import chat.revolt.internals.Autocomplete
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import logcat.logcat
|
||||||
|
|
||||||
private fun convertDpToPixel(dp: Float, context: Context): Float {
|
fun Pair<Int, Int>.asTextRange(): TextRange {
|
||||||
return dp * (context.resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)
|
return TextRange(this.first, this.second)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun String.applyAutocompleteSuggestion(
|
private fun CharSequence.isEmptyOrOnlyNewlines(): Boolean {
|
||||||
suggestion: AutocompleteSuggestion,
|
return this.lines().all { it.isEmpty() || it.all { c -> c == '\n' } }
|
||||||
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 -> {
|
private fun TextFieldState.lastWord(): String? {
|
||||||
if (suggestion.channel.name?.contains(" ", ignoreCase = true) == true) {
|
return this.text.substring(0, this.selection.min)
|
||||||
this.replaceRange(
|
.split(" ").lastOrNull()
|
||||||
cursorPosition - suggestion.query.length - 1,
|
}
|
||||||
cursorPosition,
|
|
||||||
"<#${suggestion.channel.id}> "
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.replaceRange(
|
|
||||||
cursorPosition - suggestion.query.length - 1,
|
|
||||||
cursorPosition,
|
|
||||||
"#${suggestion.channel.name} "
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is AutocompleteSuggestion.Emoji -> {
|
private fun CharSequence.lastWordStartsAt(): Int {
|
||||||
this.replaceRange(
|
return this.lastIndexOf(" ")
|
||||||
cursorPosition - suggestion.query.length - 1,
|
|
||||||
cursorPosition,
|
|
||||||
suggestion.shortcode + " "
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class AutocompleteSuggestion {
|
sealed class AutocompleteSuggestion {
|
||||||
|
|
@ -147,8 +133,8 @@ sealed class AutocompleteSuggestion {
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NativeMessageField(
|
fun MessageField(
|
||||||
value: String,
|
initialValue: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onAddAttachment: () -> Unit,
|
onAddAttachment: () -> Unit,
|
||||||
onCommitAttachment: (Uri) -> Unit,
|
onCommitAttachment: (Uri) -> Unit,
|
||||||
|
|
@ -163,10 +149,11 @@ fun NativeMessageField(
|
||||||
failedValidation: Boolean = false,
|
failedValidation: Boolean = false,
|
||||||
serverId: String? = null,
|
serverId: String? = null,
|
||||||
channelId: String? = null,
|
channelId: String? = null,
|
||||||
|
valueIsBlank: Boolean = false,
|
||||||
editMode: Boolean = false,
|
editMode: Boolean = false,
|
||||||
|
initialValueDirtyMarker: Any = Unit,
|
||||||
cancelEdit: () -> Unit = {},
|
cancelEdit: () -> Unit = {},
|
||||||
onFocusChange: (Boolean) -> Unit = {},
|
onFocusChange: (Boolean) -> Unit = {},
|
||||||
onSelectionChange: (Pair<Int, Int>) -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
val placeholderResource = when (channelType) {
|
val placeholderResource = when (channelType) {
|
||||||
ChannelType.DirectMessage -> R.string.message_field_placeholder_dm
|
ChannelType.DirectMessage -> R.string.message_field_placeholder_dm
|
||||||
|
|
@ -176,31 +163,95 @@ fun NativeMessageField(
|
||||||
ChannelType.SavedMessages -> R.string.message_field_placeholder_notes
|
ChannelType.SavedMessages -> R.string.message_field_placeholder_notes
|
||||||
}
|
}
|
||||||
|
|
||||||
var requestFocus by remember { mutableStateOf({}) }
|
|
||||||
var clearFocus by remember { mutableStateOf({}) }
|
|
||||||
|
|
||||||
val sendButtonVisible =
|
val sendButtonVisible =
|
||||||
(value.isNotBlank() || forceSendButton) && !disabled && !failedValidation
|
(!valueIsBlank || forceSendButton) && !disabled && !failedValidation
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val selectionColour = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f).toArgb()
|
|
||||||
val cursorColour = MaterialTheme.colorScheme.primary.toArgb()
|
|
||||||
val failedValidationColour = MaterialTheme.colorScheme.error.toArgb()
|
|
||||||
val contentColour = LocalContentColor.current.toArgb()
|
|
||||||
val placeholderColour = LocalContentColor.current.copy(alpha = 0.5f).toArgb()
|
|
||||||
|
|
||||||
var selection by remember { mutableStateOf(0 to 0) }
|
var selection by remember { mutableStateOf(0 to 0) }
|
||||||
val autocompleteSuggestions = remember { mutableStateListOf<AutocompleteSuggestion>() }
|
val autocompleteSuggestions = remember { mutableStateListOf<AutocompleteSuggestion>() }
|
||||||
val autocompleteSuggestionState = rememberLazyListState()
|
val autocompleteSuggestionState = rememberLazyListState()
|
||||||
|
|
||||||
|
val receiveContentListener = remember {
|
||||||
|
ReceiveContentListener { transferableContent ->
|
||||||
|
transferableContent.consume { item ->
|
||||||
|
val uri = item.uri
|
||||||
|
if (uri != null) {
|
||||||
|
onCommitAttachment(uri)
|
||||||
|
}
|
||||||
|
uri != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var textFieldState = rememberTextFieldState(
|
||||||
|
initialText = initialValue,
|
||||||
|
initialSelection = selection.asTextRange()
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(initialValue, initialValueDirtyMarker) {
|
||||||
|
logcat { "New initial value: $initialValue" }
|
||||||
|
logcat { "Old state: $textFieldState" }
|
||||||
|
textFieldState.setTextAndPlaceCursorAtEnd(initialValue)
|
||||||
|
logcat { "New state: $textFieldState" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(textFieldState.text) {
|
||||||
|
logcat { "New text is ${textFieldState.text}" }
|
||||||
|
onValueChange(textFieldState.text.toString())
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
autocompleteSuggestionState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
autocompleteSuggestions.clear()
|
||||||
|
|
||||||
|
if (textFieldState.text.isNotBlank() &&
|
||||||
|
(textFieldState.selection.min == textFieldState.selection.max)
|
||||||
|
) {
|
||||||
|
val lastWord = textFieldState.lastWord()
|
||||||
|
if (lastWord != null) {
|
||||||
|
when {
|
||||||
|
lastWord.startsWith(':') && !lastWord.endsWith(':') -> {
|
||||||
|
autocompleteSuggestions.addAll(
|
||||||
|
Autocomplete.emoji(lastWord.substring(1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastWord.startsWith('@') -> {
|
||||||
|
if (channelId != null && serverId != null) {
|
||||||
|
autocompleteSuggestions.addAll(
|
||||||
|
Autocomplete.user(
|
||||||
|
channelId,
|
||||||
|
serverId,
|
||||||
|
lastWord.substring(1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastWord.startsWith('#') -> {
|
||||||
|
if (serverId != null) {
|
||||||
|
autocompleteSuggestions.addAll(
|
||||||
|
Autocomplete.channel(
|
||||||
|
serverId,
|
||||||
|
lastWord.substring(1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(editMode) {
|
LaunchedEffect(editMode) {
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
requestFocus()
|
focusRequester.requestFocus()
|
||||||
} else {
|
} else {
|
||||||
clearFocus()
|
focusManager.clearFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,7 +259,7 @@ fun NativeMessageField(
|
||||||
modifier = modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
modifier = modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = autocompleteSuggestions.size > 0,
|
visible = autocompleteSuggestions.isNotEmpty(),
|
||||||
enter = expandIn(initialSize = { full ->
|
enter = expandIn(initialSize = { full ->
|
||||||
IntSize(
|
IntSize(
|
||||||
full.width,
|
full.width,
|
||||||
|
|
@ -241,12 +292,17 @@ fun NativeMessageField(
|
||||||
is AutocompleteSuggestion.User -> {
|
is AutocompleteSuggestion.User -> {
|
||||||
SuggestionChip(
|
SuggestionChip(
|
||||||
onClick = {
|
onClick = {
|
||||||
onValueChange(
|
textFieldState.edit {
|
||||||
value.applyAutocompleteSuggestion(
|
val lastWordStartsAt =
|
||||||
item,
|
textFieldState.text
|
||||||
selection.first
|
.substring(0, textFieldState.selection.max)
|
||||||
|
.lastWordStartsAt()
|
||||||
|
replace(
|
||||||
|
if (lastWordStartsAt == -1) 0 else (lastWordStartsAt + 1),
|
||||||
|
textFieldState.selection.max,
|
||||||
|
"@${item.user.username}#${item.user.discriminator} "
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
},
|
},
|
||||||
label = { Text("@${item.user.username}#${item.user.discriminator}") },
|
label = { Text("@${item.user.username}#${item.user.discriminator}") },
|
||||||
icon = {
|
icon = {
|
||||||
|
|
@ -269,12 +325,29 @@ fun NativeMessageField(
|
||||||
is AutocompleteSuggestion.Channel -> {
|
is AutocompleteSuggestion.Channel -> {
|
||||||
SuggestionChip(
|
SuggestionChip(
|
||||||
onClick = {
|
onClick = {
|
||||||
onValueChange(
|
textFieldState.edit {
|
||||||
value.applyAutocompleteSuggestion(
|
val lastWordStartsAt =
|
||||||
item,
|
textFieldState.text
|
||||||
selection.first
|
.substring(0, textFieldState.selection.max)
|
||||||
|
.lastWordStartsAt()
|
||||||
|
|
||||||
|
val replacement =
|
||||||
|
if (item.channel.name?.contains(
|
||||||
|
" ",
|
||||||
|
ignoreCase = true
|
||||||
|
) == true
|
||||||
|
) {
|
||||||
|
"<#${item.channel.id}> "
|
||||||
|
} else {
|
||||||
|
"#${item.channel.name} "
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(
|
||||||
|
if (lastWordStartsAt == -1) 0 else (lastWordStartsAt + 1),
|
||||||
|
textFieldState.selection.max,
|
||||||
|
replacement
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
},
|
},
|
||||||
label = { Text("#${item.channel.name}") },
|
label = { Text("#${item.channel.name}") },
|
||||||
icon = {
|
icon = {
|
||||||
|
|
@ -293,12 +366,17 @@ fun NativeMessageField(
|
||||||
is AutocompleteSuggestion.Emoji -> {
|
is AutocompleteSuggestion.Emoji -> {
|
||||||
SuggestionChip(
|
SuggestionChip(
|
||||||
onClick = {
|
onClick = {
|
||||||
onValueChange(
|
textFieldState.edit {
|
||||||
value.applyAutocompleteSuggestion(
|
val lastWordStartsAt =
|
||||||
item,
|
textFieldState.text
|
||||||
selection.first
|
.substring(0, textFieldState.selection.max)
|
||||||
|
.lastWordStartsAt()
|
||||||
|
replace(
|
||||||
|
if (lastWordStartsAt == -1) 0 else (lastWordStartsAt + 1),
|
||||||
|
textFieldState.selection.max,
|
||||||
|
item.shortcode + " "
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
},
|
},
|
||||||
label = {
|
label = {
|
||||||
if (item.custom != null) {
|
if (item.custom != null) {
|
||||||
|
|
@ -353,7 +431,7 @@ fun NativeMessageField(
|
||||||
.clickable {
|
.clickable {
|
||||||
if (!editMode) {
|
if (!editMode) {
|
||||||
// hide keyboard because it's annoying
|
// hide keyboard because it's annoying
|
||||||
clearFocus()
|
focusManager.clearFocus()
|
||||||
onAddAttachment()
|
onAddAttachment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -362,191 +440,68 @@ fun NativeMessageField(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AndroidView(
|
BasicTextField(
|
||||||
factory = { context ->
|
state = textFieldState,
|
||||||
object : androidx.appcompat.widget.AppCompatEditText(context) {
|
textStyle = LocalTextStyle.current.copy(
|
||||||
var serverId: String? = null
|
color = if (failedValidation) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
} else LocalContentColor.current
|
||||||
var ic = super.onCreateInputConnection(outAttrs)
|
),
|
||||||
EditorInfoCompat.setContentMimeTypes(
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
outAttrs,
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
arrayOf("image/*")
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
)
|
keyboardType = KeyboardType.Text,
|
||||||
val mimeTypes = ViewCompat.getOnReceiveContentMimeTypes(this)
|
imeAction = ImeAction.None,
|
||||||
if (mimeTypes != null) {
|
showKeyboardOnFocus = false
|
||||||
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 {
|
|
||||||
autocompleteSuggestionState.animateScrollToItem(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))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastWord.startsWith('@') -> {
|
|
||||||
if (channelId == null) return
|
|
||||||
autocompleteSuggestions.addAll(
|
|
||||||
Autocomplete.user(
|
|
||||||
channelId,
|
|
||||||
this.serverId,
|
|
||||||
lastWord.substring(1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastWord.startsWith('#') -> {
|
|
||||||
if (this.serverId == null) return
|
|
||||||
autocompleteSuggestions.addAll(
|
|
||||||
Autocomplete.channel(
|
|
||||||
this.serverId!!,
|
|
||||||
lastWord.substring(1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
|
||||||
return when (keyCode) {
|
|
||||||
KeyEvent.KEYCODE_ENTER -> {
|
|
||||||
if (event.isCtrlPressed && sendButtonVisible) {
|
|
||||||
onSendMessage()
|
|
||||||
true
|
|
||||||
} else super.onKeyUp(keyCode, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onKeyUp(keyCode, event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.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.invalidate()
|
|
||||||
}
|
|
||||||
it.hint = it.context.getString(placeholderResource, channelName)
|
|
||||||
it.serverId = serverId
|
|
||||||
if (failedValidation) {
|
|
||||||
it.setTextColor(failedValidationColour)
|
|
||||||
} else {
|
|
||||||
it.setTextColor(contentColour)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (it.text?.isEmpty() == true) {
|
|
||||||
it.maxLines = 1
|
|
||||||
} else {
|
|
||||||
it.maxLines = 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.testTag("message_field")
|
.heightIn(max = 128.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.onFocusChanged {
|
||||||
|
onFocusChange(it.isFocused)
|
||||||
|
}
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.contentReceiver(receiveContentListener)
|
||||||
|
.onKeyEvent {
|
||||||
|
if (it.type == KeyEventType.KeyUp) {
|
||||||
|
when {
|
||||||
|
it.key == Key.Enter &&
|
||||||
|
!it.isShiftPressed &&
|
||||||
|
!it.isAltPressed &&
|
||||||
|
it.isCtrlPressed &&
|
||||||
|
!it.isMetaPressed -> {
|
||||||
|
onSendMessage()
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
|
||||||
|
it.key == Key.Escape &&
|
||||||
|
!it.isShiftPressed &&
|
||||||
|
!it.isAltPressed &&
|
||||||
|
!it.isCtrlPressed &&
|
||||||
|
!it.isMetaPressed -> {
|
||||||
|
cancelEdit()
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@onKeyEvent false
|
||||||
|
},
|
||||||
|
decorator = { innerTextField ->
|
||||||
|
Box(Modifier.padding(horizontal = 16.dp, vertical = 18.dp)) {
|
||||||
|
if (textFieldState.text.isEmptyOrOnlyNewlines()) {
|
||||||
|
Text(
|
||||||
|
stringResource(placeholderResource, channelName),
|
||||||
|
style = LocalTextStyle.current.copy(
|
||||||
|
color = LocalContentColor.current.copy(alpha = 0.5f)
|
||||||
|
),
|
||||||
|
modifier = Modifier.align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -557,7 +512,7 @@ fun NativeMessageField(
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clickable {
|
.clickable {
|
||||||
clearFocus()
|
focusManager.clearFocus()
|
||||||
onPickEmoji()
|
onPickEmoji()
|
||||||
}
|
}
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
|
|
@ -610,8 +565,8 @@ fun NativeMessageField(
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun NativeMessageFieldPreview() {
|
fun NativeMessageFieldPreview() {
|
||||||
NativeMessageField(
|
MessageField(
|
||||||
value = "Hello world!",
|
initialValue = "Hello world!",
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
onAddAttachment = {},
|
onAddAttachment = {},
|
||||||
onCommitAttachment = {},
|
onCommitAttachment = {},
|
||||||
|
|
@ -626,6 +581,5 @@ fun NativeMessageFieldPreview() {
|
||||||
editMode = false,
|
editMode = false,
|
||||||
cancelEdit = {},
|
cancelEdit = {},
|
||||||
onFocusChange = {},
|
onFocusChange = {},
|
||||||
onSelectionChange = {}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +122,7 @@ import chat.revolt.callbacks.Action
|
||||||
import chat.revolt.callbacks.ActionChannel
|
import chat.revolt.callbacks.ActionChannel
|
||||||
import chat.revolt.components.chat.DateDivider
|
import chat.revolt.components.chat.DateDivider
|
||||||
import chat.revolt.components.chat.Message
|
import chat.revolt.components.chat.Message
|
||||||
import chat.revolt.components.chat.NativeMessageField
|
import chat.revolt.components.chat.MessageField
|
||||||
import chat.revolt.components.chat.SystemMessage
|
import chat.revolt.components.chat.SystemMessage
|
||||||
import chat.revolt.components.emoji.EmojiPicker
|
import chat.revolt.components.emoji.EmojiPicker
|
||||||
import chat.revolt.components.generic.GroupIcon
|
import chat.revolt.components.generic.GroupIcon
|
||||||
|
|
@ -969,7 +969,7 @@ fun ChannelScreen(
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.editingMessage = null
|
viewModel.editingMessage = null
|
||||||
viewModel.putDraftContent("")
|
viewModel.putDraftContent("", true)
|
||||||
},
|
},
|
||||||
label = {
|
label = {
|
||||||
Text(stringResource(R.string.message_field_editing_message))
|
Text(stringResource(R.string.message_field_editing_message))
|
||||||
|
|
@ -992,8 +992,9 @@ fun ChannelScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NativeMessageField(
|
MessageField(
|
||||||
value = viewModel.draftContent,
|
initialValue = viewModel.initialTextFieldValue,
|
||||||
|
initialValueDirtyMarker = viewModel.initialTextFieldValueDirtyMarker,
|
||||||
onValueChange = viewModel::putDraftContent,
|
onValueChange = viewModel::putDraftContent,
|
||||||
onAddAttachment = {
|
onAddAttachment = {
|
||||||
if (viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
|
if (viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
|
||||||
|
|
@ -1032,6 +1033,11 @@ fun ChannelScreen(
|
||||||
serverId = viewModel.channel?.server,
|
serverId = viewModel.channel?.server,
|
||||||
channelId = channelId,
|
channelId = channelId,
|
||||||
failedValidation = viewModel.draftContent.length > 2000,
|
failedValidation = viewModel.draftContent.length > 2000,
|
||||||
|
valueIsBlank = viewModel.draftContent.isBlank(),
|
||||||
|
cancelEdit = {
|
||||||
|
viewModel.editingMessage = null
|
||||||
|
viewModel.putDraftContent("", true)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,14 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
var activePane by mutableStateOf<ChannelScreenActivePane>(ChannelScreenActivePane.None)
|
var activePane by mutableStateOf<ChannelScreenActivePane>(ChannelScreenActivePane.None)
|
||||||
var keyboardHeight by mutableIntStateOf(0)
|
var keyboardHeight by mutableIntStateOf(0)
|
||||||
|
|
||||||
|
// Setting [initialTextFieldValue] causes the text field to switch value.
|
||||||
|
// However [initialTextFieldValue] gets de-synced with the actual text field value when the user types.
|
||||||
|
// For a field that keeps track of the actual text field value, see [draftContent].
|
||||||
|
// Setting [draftContent] does not cause the text field to switch value.
|
||||||
|
// Note that if [initialTextFieldValue] is set to the same value it has now, the text field will not switch value.
|
||||||
|
// Set [initialTextFieldValueDirtyMarker] to a new value to force the text field to switch value.
|
||||||
|
var initialTextFieldValue by mutableStateOf("")
|
||||||
|
var initialTextFieldValueDirtyMarker by mutableStateOf("")
|
||||||
var draftContent by mutableStateOf("")
|
var draftContent by mutableStateOf("")
|
||||||
var draftAttachments = mutableStateListOf<FileArgs>()
|
var draftAttachments = mutableStateListOf<FileArgs>()
|
||||||
var draftReplyTo = mutableStateListOf<SendMessageReply>()
|
var draftReplyTo = mutableStateListOf<SendMessageReply>()
|
||||||
|
|
@ -198,7 +206,7 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putAtCursorPosition(text: String) {
|
fun putAtCursorPosition(text: String) {
|
||||||
putDraftContent(draftContent + text)
|
putDraftContent(draftContent + text, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var lastSentBeginTyping: Instant? = null
|
private var lastSentBeginTyping: Instant? = null
|
||||||
|
|
@ -241,7 +249,11 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putDraftContent(content: String) {
|
/**
|
||||||
|
* Puts the draft content in the KV storage, the in-memory state of the message content,
|
||||||
|
* and, if [setInitial] is true, updates the text field to say the new [content].
|
||||||
|
*/
|
||||||
|
fun putDraftContent(content: String, setInitial: Boolean = false) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
kvStorage.set("draftContent/${channel?.id}", content)
|
kvStorage.set("draftContent/${channel?.id}", content)
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +269,10 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
draftContent = content
|
draftContent = content
|
||||||
|
if (setInitial) {
|
||||||
|
initialTextFieldValue = content
|
||||||
|
initialTextFieldValueDirtyMarker = ULID.makeNext()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addReplyTo(messageId: String) {
|
suspend fun addReplyTo(messageId: String) {
|
||||||
|
|
@ -295,7 +311,7 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
messageId = editingMessage ?: return,
|
messageId = editingMessage ?: return,
|
||||||
newContent = draftContent,
|
newContent = draftContent,
|
||||||
)
|
)
|
||||||
putDraftContent("")
|
putDraftContent("", true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("ChannelScreenViewModel", "Failed to edit message", e)
|
Log.e("ChannelScreenViewModel", "Failed to edit message", e)
|
||||||
}
|
}
|
||||||
|
|
@ -368,7 +384,7 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
updateItems(listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage)) + items)
|
updateItems(listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage)) + items)
|
||||||
|
|
||||||
kvStorage.remove("draftContent/${channel?.id}")
|
kvStorage.remove("draftContent/${channel?.id}")
|
||||||
putDraftContent("")
|
putDraftContent("", true)
|
||||||
draftReplyTo.clear()
|
draftReplyTo.clear()
|
||||||
attachmentUploadProgress = 0f
|
attachmentUploadProgress = 0f
|
||||||
|
|
||||||
|
|
@ -723,7 +739,7 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
|
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
|
||||||
} as? ChannelScreenItem.RegularMessage ?: return@onEach
|
} as? ChannelScreenItem.RegularMessage ?: return@onEach
|
||||||
|
|
||||||
putDraftContent(message.message.content ?: "")
|
putDraftContent(message.message.content ?: "", true)
|
||||||
this@ChannelScreenViewModel.draftAttachments.clear()
|
this@ChannelScreenViewModel.draftAttachments.clear()
|
||||||
draftReplyTo.clear()
|
draftReplyTo.clear()
|
||||||
}
|
}
|
||||||
|
|
@ -740,7 +756,7 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
shouldMention
|
shouldMention
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
putDraftContent(it.content)
|
putDraftContent(it.content, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.catch {
|
}.catch {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue