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:
parent
3195037d8a
commit
65e07fba6f
9 changed files with 319 additions and 103 deletions
|
@ -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 = ""
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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"
|
Loading…
Add table
Add a link
Reference in a new issue