1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-3308 Space-level chat | Enhancement | Basics for chatbox mentions - 1 (#2041)

This commit is contained in:
Evgenii Kozlov 2025-01-28 19:34:25 +01:00 committed by konstantiniiv
parent 3195037d8a
commit 65e07fba6f
9 changed files with 319 additions and 103 deletions

View file

@ -42,7 +42,8 @@ sealed class Chat {
fun new(
text: String,
attachments: List<Attachment> = emptyList(),
replyToMessageId: Id? = null
replyToMessageId: Id? = null,
marks: List<Block.Content.Text.Mark>
) : Message = Message(
id = "",
createdAt = 0L,
@ -53,7 +54,7 @@ sealed class Chat {
replyToMessageId = replyToMessageId,
content = Content(
text = text,
marks = emptyList(),
marks = marks,
style = Block.Content.Text.Style.P
),
order = ""

View file

@ -13,7 +13,7 @@ fun String.splitByMarks(
// Populate the style map with styles for each index in the ranges
for (styledRange in marks) {
for (index in styledRange.range) {
for (index in styledRange.range.first until styledRange.range.last) {
if (index in indices) {
// Add the style to the current index, initializing the list if needed
styleMap.computeIfAbsent(index) { mutableListOf() }.add(styledRange)
@ -28,7 +28,6 @@ fun String.splitByMarks(
// Get the styles at the current index (or an empty list if none)
val stylesAtCurrentIndex = styleMap[currentIndex] ?: emptyList()
// Find the extent of this style group (i.e., where styles change)
var endIndex = currentIndex
while (endIndex < length && (styleMap[endIndex] ?: emptyList()) == stylesAtCurrentIndex) {

View file

@ -25,11 +25,13 @@ import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionCon
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer.Store
import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
import com.anytypeio.anytype.feature_chats.BuildConfig
import com.anytypeio.anytype.presentation.common.BaseViewModel
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.home.navigation
import com.anytypeio.anytype.presentation.mapper.objectIcon
import com.anytypeio.anytype.presentation.objects.ObjectIcon
import com.anytypeio.anytype.presentation.objects.SpaceMemberIconView
import com.anytypeio.anytype.presentation.search.GlobalSearchItemView
import com.anytypeio.anytype.presentation.spaces.SpaceGradientProvider
import com.anytypeio.anytype.presentation.spaces.SpaceIconView
@ -63,7 +65,7 @@ class ChatViewModel @Inject constructor(
private val uploadFile: UploadFile,
private val storeOfObjectTypes: StoreOfObjectTypes,
private val copyFileToCacheDirectory: CopyFileToCacheDirectory,
private val exitToVaultDelegate: ExitToVaultDelegate
private val exitToVaultDelegate: ExitToVaultDelegate,
) : BaseViewModel(), ExitToVaultDelegate by exitToVaultDelegate {
val header = MutableStateFlow<HeaderView>(HeaderView.Init)
@ -73,6 +75,7 @@ class ChatViewModel @Inject constructor(
val uXCommands = MutableSharedFlow<UXCommand>()
val navigation = MutableSharedFlow<OpenObjectNavigation>()
val chatBoxMode = MutableStateFlow<ChatBoxMode>(ChatBoxMode.Default)
val mentionPanelState = MutableStateFlow<MentionPanelState>(MentionPanelState.Hidden)
private val dateFormatter = SimpleDateFormat("d MMMM YYYY")
private val data = MutableStateFlow<List<Chat.Message>>(emptyList())
@ -255,9 +258,42 @@ class ChatViewModel @Inject constructor(
messages.value = it
}
}
fun onChatBoxInputChanged(
selection: IntRange,
text: String
) {
if (isMentionTriggered(text, selection.start)) {
mentionPanelState.value = MentionPanelState.Visible(
results = members.get().let { store ->
when(store) {
is Store.Data -> {
store.members.map { member ->
MentionPanelState.Member(
member.id,
name = member.name.orEmpty(),
icon = SpaceMemberIconView.icon(
obj = member,
urlBuilder = urlBuilder
)
)
}
}
Store.Empty -> {
emptyList()
}
}
}
)
} else {
mentionPanelState.value = MentionPanelState.Hidden
}
}
fun onMessageSent(msg: String) {
Timber.d("DROID-2635 OnMessageSent: $msg")
fun onMessageSent(msg: String, markup: List<Block.Content.Text.Mark>) {
if (BuildConfig.DEBUG) {
Timber.d("DROID-2635 OnMessageSent, markup: $markup}")
}
viewModelScope.launch {
val attachments = buildList {
chatBoxAttachments.value.forEach { attachment ->
@ -321,7 +357,8 @@ class ChatViewModel @Inject constructor(
chat = vmParams.ctx,
message = Chat.Message.new(
text = msg,
attachments = attachments
attachments = attachments,
marks = markup
)
)
).onSuccess { (id, payload) ->
@ -363,7 +400,8 @@ class ChatViewModel @Inject constructor(
message = Chat.Message.new(
text = msg,
replyToMessageId = mode.msg,
attachments = attachments
attachments = attachments,
marks = markup
)
)
).onSuccess { (id, payload) ->
@ -594,6 +632,19 @@ class ChatViewModel @Inject constructor(
}
}
private fun isMentionTriggered(text: String, selectionStart: Int): Boolean {
// Ensure selectionStart is valid and not out of bounds
if (selectionStart <= 0 || selectionStart > text.length) {
return false
}
// Check the character before the cursor position
val previousChar = text[selectionStart - 1]
// Trigger mention if the previous character is '@'
return previousChar == '@'
}
sealed class ViewModelCommand {
data object Exit : ViewModelCommand()
data object OpenWidgets : ViewModelCommand()
@ -619,6 +670,17 @@ class ChatViewModel @Inject constructor(
): ChatBoxMode()
}
sealed class MentionPanelState {
data object Hidden : MentionPanelState()
data class Visible(val results: List<Member>) : MentionPanelState()
data class Member(
val id: Id,
val name: String,
val icon: SpaceMemberIconView,
val isUser: Boolean = false
)
}
sealed class HeaderView {
data object Init : HeaderView()
data class Default(

View file

@ -26,8 +26,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.DropdownMenu
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@ -49,7 +47,6 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
@ -67,24 +64,27 @@ import com.anytypeio.anytype.presentation.confgs.ChatConfig
import com.anytypeio.anytype.presentation.objects.ObjectIcon
import kotlin.collections.forEach
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun ChatBox(
text: TextFieldValue,
spans: List<ChatBoxSpan>,
mode: ChatBoxMode = ChatBoxMode.Default,
modifier: Modifier = Modifier,
chatBoxFocusRequester: FocusRequester,
textState: TextFieldValue,
onMessageSent: (String) -> Unit = {},
onMessageSent: (String, List<ChatBoxSpan>) -> Unit,
resetScroll: () -> Unit = {},
attachments: List<ChatView.Message.ChatBoxAttachment>,
clearText: () -> Unit,
updateValue: (TextFieldValue) -> Unit,
onAttachObjectClicked: () -> Unit,
onClearAttachmentClicked: (ChatView.Message.ChatBoxAttachment) -> Unit,
onClearReplyClicked: () -> Unit,
onChatBoxMediaPicked: (List<Uri>) -> Unit,
onChatBoxFilePicked: (List<Uri>) -> Unit,
onExitEditMessageMode: () -> Unit
onExitEditMessageMode: () -> Unit,
onValueChange: (TextFieldValue, List<ChatBoxSpan>) -> Unit
) {
val uploadMediaLauncher = rememberLauncherForActivityResult(
@ -378,17 +378,16 @@ fun ChatBox(
}
}
ChatBoxUserInput(
textState = textState,
onTextChanged = { value ->
updateValue(value)
},
text = text,
spans = spans,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.align(Alignment.Bottom)
.focusRequester(chatBoxFocusRequester)
)
AnimatedVisibility(
visible = attachments.isNotEmpty() || textState.text.isNotEmpty(),
visible = attachments.isNotEmpty() || text.text.isNotEmpty(),
exit = fadeOut() + scaleOut(),
enter = fadeIn() + scaleIn(),
modifier = Modifier.align(Alignment.Bottom)
@ -398,7 +397,7 @@ fun ChatBox(
.padding(horizontal = 4.dp, vertical = 8.dp)
.clip(CircleShape)
.clickable {
onMessageSent(textState.text)
onMessageSent(text.text, spans)
clearText()
resetScroll()
}
@ -419,12 +418,58 @@ fun ChatBox(
@Composable
private fun ChatBoxUserInput(
modifier: Modifier,
textState: TextFieldValue,
onTextChanged: (TextFieldValue) -> Unit,
text: TextFieldValue,
spans: List<ChatBoxSpan>,
onValueChange: (TextFieldValue, List<ChatBoxSpan>) -> Unit
) {
BasicTextField(
value = textState,
onValueChange = { onTextChanged(it) },
value = text,
onValueChange = { newValue ->
val newText = newValue.text
val oldText = text.text // Keep a reference to the current text before updating
val textLengthDifference = newText.length - oldText.length
val updatedSpans = spans.mapNotNull { span ->
// Detect the common prefix length
val commonPrefixLength = newText.commonPrefixWith(oldText).length
// Adjust span ranges based on text changes
val newStart = when {
// Insertion shifts spans after the insertion point
textLengthDifference > 0 && commonPrefixLength <= span.start -> span.start + textLengthDifference
// Deletion shifts spans after the deletion point
textLengthDifference < 0 && commonPrefixLength <= span.start -> span.start + textLengthDifference
else -> span.start
}.coerceAtLeast(0) // Ensure bounds are valid
val newEnd = when {
// Insertion shifts spans after the insertion point
textLengthDifference > 0 && commonPrefixLength < span.end -> span.end + textLengthDifference
// Deletion shifts spans after the deletion point
textLengthDifference < 0 && commonPrefixLength < span.end -> span.end + textLengthDifference
else -> span.end
}.coerceAtLeast(newStart).coerceAtMost(newText.length) // Ensure bounds are valid
// Log changes for debugging
Timber.d("Text length: ${newText.length}, Old interval: ${span.start}, ${span.end}, New interval: $newStart, $newEnd")
// Remove span if the entire range is deleted or invalid
if (newStart < newEnd && newText.substring(newStart, newEnd).isNotBlank()) {
when(span) {
is ChatBoxSpan.Mention -> {
span.copy(start = newStart, end = newEnd)
}
}
} else {
Timber.d("Removing span: $span")
null // Remove invalid or deleted spans
}
}
// Notify parent with the updated text and spans
onValueChange(newValue, updatedSpans)
},
textStyle = BodyRegular.copy(
color = colorResource(id = R.color.text_primary)
),
@ -440,11 +485,12 @@ private fun ChatBoxUserInput(
maxLines = 5,
decorationBox = @Composable { innerTextField ->
DefaultHintDecorationBox(
text = textState.text,
text = text.text,
hint = stringResource(R.string.write_a_message),
innerTextField = innerTextField,
textStyle = BodyRegular.copy(color = colorResource(R.color.text_tertiary))
)
}
},
visualTransformation = AnnotatedTextTransformation(spans)
)
}

View file

@ -230,7 +230,7 @@ fun Bubble(
if (part.link != null && part.link.param != null) {
withLink(
LinkAnnotation.Clickable(
tag = "link",
tag = DEFAULT_MENTION_LINK_TAG,
styles = TextLinkStyles(
style = SpanStyle(
fontWeight = if (part.isBold) FontWeight.Bold else null,
@ -247,7 +247,7 @@ fun Bubble(
} else if (part.mention != null && part.mention.param != null) {
withLink(
LinkAnnotation.Clickable(
tag = "@-mention",
tag = DEFAULT_MENTION_SPAN_TAG,
styles = TextLinkStyles(
style = SpanStyle(
fontWeight = if (part.isBold) FontWeight.Bold else null,

View file

@ -103,7 +103,7 @@ fun ChatScreenPreview() {
)
}
}.reversed(),
onMessageSent = {},
onMessageSent = { a, b -> },
attachments = emptyList(),
onClearAttachmentClicked = {},
lazyListState = LazyListState(),
@ -123,7 +123,9 @@ fun ChatScreenPreview() {
onAddReactionClicked = {},
onViewChatReaction = { a, b -> },
onMemberIconClicked = {},
onMentionClicked = {}
onMentionClicked = {},
mentionPanelState = ChatViewModel.MentionPanelState.Hidden,
onTextChanged = {}
)
}

View file

@ -1,6 +1,5 @@
package com.anytypeio.anytype.feature_chats.ui
import com.anytypeio.anytype.feature_chats.R
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -21,10 +20,12 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.features.multiplayer.SpaceMemberIcon
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.foundation.Dragger
import com.anytypeio.anytype.core_ui.views.BodyCallout
import com.anytypeio.anytype.core_ui.views.BodyRegular
import com.anytypeio.anytype.core_ui.views.Relations3
import com.anytypeio.anytype.feature_chats.R
import com.anytypeio.anytype.feature_chats.presentation.ChatReactionViewModel.ViewState
import com.anytypeio.anytype.presentation.objects.SpaceMemberIconView
@ -63,9 +64,14 @@ fun ChatReactionScreen(
items(
count = viewState.members.size
) { idx ->
Member(
member = viewState.members[idx]
val member = viewState.members[idx]
ChatMemberItem(
name = member.name,
icon = member.icon
)
if (idx != viewState.members.lastIndex) {
Divider()
}
}
}
is ViewState.Error.MessageNotFound -> {
@ -91,9 +97,10 @@ fun ChatReactionScreen(
}
@Composable
private fun Member(
fun ChatMemberItem(
modifier: Modifier = Modifier,
member: ViewState.Member
name: String,
icon: SpaceMemberIconView
) {
Box(
modifier = modifier
@ -102,7 +109,7 @@ private fun Member(
.padding(horizontal = 16.dp)
) {
SpaceMemberIcon(
icon = member.icon,
icon = icon,
iconSize = 48.dp,
modifier = Modifier.align(
alignment = Alignment.CenterStart
@ -115,7 +122,7 @@ private fun Member(
.padding(start = 60.dp)
) {
Text(
text = member.name.ifEmpty {
text = name.ifEmpty {
stringResource(R.string.untitled)
},
color = colorResource(R.color.text_primary)
@ -192,13 +199,10 @@ private fun EmptyState(
@DefaultPreviews
@Composable
private fun MemberPreview() {
Member(
member = ViewState.Member(
name = "Walter Benjamin",
icon = SpaceMemberIconView.Placeholder(
name = "Walter"
),
isUser = false
ChatMemberItem(
name = "Walter Benjamin",
icon = SpaceMemberIconView.Placeholder(
name = "Walter"
)
)
}

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@ -42,7 +43,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
@ -52,19 +52,25 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.anytypeio.anytype.core_models.Block
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_ui.foundation.AlertConfig
import com.anytypeio.anytype.core_ui.foundation.AlertIcon
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.foundation.GRADIENT_TYPE_BLUE
import com.anytypeio.anytype.core_ui.foundation.noRippleClickable
import com.anytypeio.anytype.core_ui.views.Caption1Medium
import com.anytypeio.anytype.core_ui.views.Caption1Regular
import com.anytypeio.anytype.core_ui.views.PreviewTitle2Regular
@ -74,6 +80,7 @@ import com.anytypeio.anytype.feature_chats.R
import com.anytypeio.anytype.feature_chats.presentation.ChatView
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.ChatBoxMode
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.MentionPanelState
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.UXCommand
import kotlinx.coroutines.launch
@ -110,7 +117,22 @@ fun ChatScreenWrapper(
chatBoxMode = vm.chatBoxMode.collectAsState().value,
messages = vm.messages.collectAsState().value,
attachments = vm.chatBoxAttachments.collectAsState().value,
onMessageSent = vm::onMessageSent,
onMessageSent = { text, spans ->
vm.onMessageSent(
msg = text,
markup = spans.map { span ->
when(span) {
is ChatBoxSpan.Mention -> {
Block.Content.Text.Mark(
type = Block.Content.Text.Mark.Type.MENTION,
param = span.param,
range = span.start..span.end
)
}
}
}
)
},
onClearAttachmentClicked = vm::onClearAttachmentClicked,
lazyListState = lazyListState,
onReacted = vm::onReacted,
@ -155,7 +177,14 @@ fun ChatScreenWrapper(
onAddReactionClicked = onSelectChatReaction,
onViewChatReaction = onViewChatReaction,
onMemberIconClicked = vm::onMemberIconClicked,
onMentionClicked = vm::onMentionClicked
onMentionClicked = vm::onMentionClicked,
mentionPanelState = vm.mentionPanelState.collectAsStateWithLifecycle().value,
onTextChanged = { value ->
vm.onChatBoxInputChanged(
selection = value.selection.start..value.selection.end,
text = value.text
)
}
)
LaunchedEffect(Unit) {
vm.uXCommands.collect { command ->
@ -197,11 +226,12 @@ fun ChatScreenWrapper(
*/
@Composable
fun ChatScreen(
mentionPanelState: MentionPanelState,
chatBoxMode: ChatBoxMode,
lazyListState: LazyListState,
messages: List<ChatView>,
attachments: List<ChatView.Message.ChatBoxAttachment>,
onMessageSent: (String) -> Unit,
onMessageSent: (String, List<ChatBoxSpan>) -> Unit,
onClearAttachmentClicked: (ChatView.Message.ChatBoxAttachment) -> Unit,
onClearReplyClicked: () -> Unit,
onReacted: (Id, String) -> Unit,
@ -218,12 +248,15 @@ fun ChatScreen(
onAddReactionClicked: (String) -> Unit,
onViewChatReaction: (Id, String) -> Unit,
onMemberIconClicked: (Id?) -> Unit,
onMentionClicked: (Id) -> Unit
onMentionClicked: (Id) -> Unit,
onTextChanged: (TextFieldValue) -> Unit
) {
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
var text by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
var spans by remember { mutableStateOf<List<ChatBoxSpan>>(emptyList()) }
val chatBoxFocusRequester = FocusRequester()
val scope = rememberCoroutineScope()
@ -252,7 +285,7 @@ fun ChatScreen(
onAttachmentClicked = onAttachmentClicked,
onEditMessage = { msg ->
onEditMessage(msg).also {
textState = TextFieldValue(
text = TextFieldValue(
msg.content.msg,
selection = TextRange(msg.content.msg.length)
)
@ -295,6 +328,71 @@ fun ChatScreen(
},
enabled = jumpToBottomButtonEnabled
)
when(mentionPanelState) {
MentionPanelState.Hidden -> {
// Draw nothing.
}
is MentionPanelState.Visible -> {
LazyColumn(
modifier = Modifier
.padding(12.dp)
.fillMaxWidth()
.height(168.dp)
.background(
color = colorResource(R.color.background_primary),
shape = RoundedCornerShape(12.dp)
)
.align(Alignment.BottomCenter)
) {
items(
items = mentionPanelState.results,
key = { member -> member.id }
) { member ->
ChatMemberItem(
name = member.name,
icon = member.icon,
modifier = Modifier
.fillMaxWidth()
.noRippleClickable {
val start = text.selection.start
val end = text.selection.end
val input = text.text
val adjustedStart = (start - 1).coerceAtLeast(0)
val replacementText = member.name + " "
val updatedText = input.replaceRange(
startIndex = adjustedStart,
endIndex = end,
replacement = replacementText
)
text = text.copy(
text = updatedText,
selection = TextRange(
index = (adjustedStart + replacementText.length))
)
val mentionSpan = ChatBoxSpan.Mention(
start = adjustedStart,
end = adjustedStart + member.name.length,
style = SpanStyle(
textDecoration = TextDecoration.Underline
),
param = member.id
)
spans = spans + mentionSpan
onTextChanged(text)
}
)
Divider()
}
}
}
}
}
ChatBox(
mode = chatBoxMode,
@ -302,8 +400,9 @@ fun ChatScreen(
.imePadding()
.navigationBarsPadding(),
chatBoxFocusRequester = chatBoxFocusRequester,
textState = textState,
onMessageSent = onMessageSent,
onMessageSent = { text, markup ->
onMessageSent(text, markup)
},
resetScroll = {
if (lazyListState.firstVisibleItemScrollOffset > 0) {
scope.launch {
@ -312,11 +411,8 @@ fun ChatScreen(
}
},
attachments = attachments,
updateValue = {
textState = it
},
clearText = {
textState = TextFieldValue()
text = TextFieldValue()
},
onAttachObjectClicked = onAttachObjectClicked,
onClearAttachmentClicked = onClearAttachmentClicked,
@ -325,9 +421,18 @@ fun ChatScreen(
onChatBoxFilePicked = onChatBoxFilePicked,
onExitEditMessageMode = {
onExitEditMessageMode().also {
textState = TextFieldValue()
text = TextFieldValue()
}
}
},
onValueChange = { t, s ->
text = t
spans = s
onTextChanged(
t
)
},
text = text,
spans = spans
)
}
}
@ -442,7 +547,9 @@ fun Messages(
Text(
text = msg.formattedDate,
style = Caption1Medium,
modifier = Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
textAlign = TextAlign.Center,
color = colorResource(R.color.transparent_active)
)

View file

@ -18,12 +18,18 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_ui.views.HeadlineTitle
import com.anytypeio.anytype.feature_chats.R
import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -58,45 +64,34 @@ fun DefaultHintDecorationBox(
)
}
@Composable
fun DiscussionTitle(
title: String?,
onTitleChanged: (String) -> Unit = {},
onFocusChanged: (Boolean) -> Unit = {}
) {
var lastFocusState by remember { mutableStateOf(false) }
BasicTextField(
textStyle = HeadlineTitle.copy(
color = colorResource(id = R.color.text_primary)
),
value = title.orEmpty(),
onValueChange = {
onTitleChanged(it)
},
modifier = Modifier
.padding(
top = 24.dp,
start = 20.dp,
end = 20.dp,
bottom = 8.dp
)
.onFocusChanged { state ->
if (lastFocusState != state.isFocused) {
onFocusChanged(state.isFocused)
class AnnotatedTextTransformation(
private val spans: List<ChatBoxSpan>
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val annotatedString = AnnotatedString.Builder(text).apply {
spans.forEach { span ->
if (span.start in text.indices && span.end <= text.length) {
addStyle(span.style, span.start, span.end)
}
lastFocusState = state.isFocused
}
,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
decorationBox = @Composable { innerTextField ->
DefaultHintDecorationBox(
hint = stringResource(id = R.string.untitled),
text = title.orEmpty(),
innerTextField = innerTextField,
textStyle = HeadlineTitle
)
}
)
}
}.toAnnotatedString()
return TransformedText(annotatedString, offsetMapping = OffsetMapping.Identity)
}
}
sealed class ChatBoxSpan {
abstract val start: Int
abstract val end: Int
abstract val style: SpanStyle
data class Mention(
override val style: SpanStyle,
override val start: Int,
override val end: Int,
val param: Id
) : ChatBoxSpan()
}
const val DEFAULT_MENTION_SPAN_TAG = "@-mention"
const val DEFAULT_MENTION_LINK_TAG = "link"