feat: compose-ify the message field

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-03-07 00:09:18 +01:00
parent 35c28bfde3
commit e117bf4b74
4 changed files with 269 additions and 293 deletions

View File

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

View File

@ -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 = {}
) )
} }

View File

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

View File

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