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

DROID-3096 Chats | Enhancement | Render markup in chat bubbles (#1836)

This commit is contained in:
Evgenii Kozlov 2024-11-23 11:26:28 +01:00 committed by GitHub
parent cd714a5856
commit 69a6819814
Signed by: github
GPG key ID: B5690EEEBB952194
8 changed files with 342 additions and 78 deletions

View file

@ -78,6 +78,9 @@ class DiscussionFragment : BaseComposeFragment() {
},
onBackButtonClicked = {
// TODO
},
onMarkupLinkClicked = {
}
)

View file

@ -37,6 +37,8 @@ import com.anytypeio.anytype.core_ui.extensions.throttledClick
import com.anytypeio.anytype.core_utils.ext.arg
import com.anytypeio.anytype.core_utils.ext.argOrNull
import com.anytypeio.anytype.core_utils.ext.toast
import com.anytypeio.anytype.core_utils.intents.SystemAction
import com.anytypeio.anytype.core_utils.intents.proceedWithAction
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.core_utils.ui.BaseComposeFragment
import com.anytypeio.anytype.di.common.componentManager
@ -194,6 +196,11 @@ class HomeScreenFragment : BaseComposeFragment(),
},
onBackButtonClicked = {
findNavController().popBackStack()
},
onMarkupLinkClicked = {
proceedWithAction(
SystemAction.OpenUrl(it)
)
}
)
}

View file

@ -0,0 +1,46 @@
package com.anytypeio.anytype.core_ui.text
import com.anytypeio.anytype.core_models.Block
fun String.splitByMarks(
marks: List<Block.Content.Text.Mark>
) : List<Pair<String, List<Block.Content.Text.Mark>>> {
if (this.isEmpty()) return emptyList()
// Create a map to track styles applied to each character index
val styleMap = mutableMapOf<Int, MutableList<Block.Content.Text.Mark>>()
// Populate the style map with styles for each index in the ranges
for (styledRange in marks) {
for (index in styledRange.range) {
if (index in indices) {
// Add the style to the current index, initializing the list if needed
styleMap.computeIfAbsent(index) { mutableListOf() }.add(styledRange)
}
}
}
val result = mutableListOf<Pair<String, List<Block.Content.Text.Mark>>>()
var currentIndex = 0
while (currentIndex < length) {
// 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) {
endIndex++
}
// Add the substring and its associated styles to the result
result.add(substring(currentIndex until endIndex) to stylesAtCurrentIndex)
// Move to the next group
currentIndex = endIndex
}
return result
}

View file

@ -0,0 +1,122 @@
package com.anytypeio.anytype.core_ui.text
import com.anytypeio.anytype.core_models.Block.Content.Text.Mark
import kotlin.test.Test
import kotlin.test.assertEquals
class SplitByMarkTests {
@Test
fun testNoMarks() {
val input = "Hello, world!"
val marks = emptyList<Mark>()
val result = input.splitByMarks(marks)
val expected = listOf(input to emptyList<Mark>())
assertEquals(expected, result)
}
@Test
fun testSingleMark() {
val input = "Hello, world!"
val marks = listOf(
Mark(0..4, Mark.Type.BOLD) // "Hello" styled as bold
)
val result = input.splitByMarks(marks)
val expected = listOf(
"Hello" to listOf(marks[0]),
", world!" to emptyList()
)
assertEquals(expected, result)
}
@Test
fun testOverlappingMarks() {
val input = "Hello, world!"
val marks = listOf(
Mark(0..4, Mark.Type.BOLD), // "Hello" styled as bold
Mark(3..7, Mark.Type.ITALIC) // Overlaps "lo, w" with italic
)
val result = input.splitByMarks(marks)
val expected = listOf(
"Hel" to listOf(marks[0]),
"lo" to listOf(marks[0], marks[1]),
", w" to listOf(marks[1]),
"orld!" to emptyList()
)
assertEquals(expected, result)
}
@Test
fun testMultipleAdjacentMarks() {
val input = "Hello, world!"
val marks = listOf(
Mark(0..4, Mark.Type.BOLD), // "Hello" styled as bold
Mark(7..11, Mark.Type.ITALIC) // "world" styled as italic
)
val result = input.splitByMarks(marks)
val expected = listOf(
"Hello" to listOf(marks[0]),
", " to emptyList(),
"world" to listOf(marks[1]),
"!" to emptyList()
)
assertEquals(expected, result)
}
@Test
fun testOutOfBoundsMarks() {
val input = "Short text"
val marks = listOf(
Mark(0..4, Mark.Type.BOLD), // Valid range
Mark(10..15, Mark.Type.ITALIC) // Out-of-bounds, should be ignored
)
val result = input.splitByMarks(marks)
val expected = listOf(
"Short" to listOf(marks[0]),
" text" to emptyList()
)
assertEquals(expected, result)
}
@Test
fun testEmptyString() {
val input = ""
val marks = listOf(
Mark(0..4, Mark.Type.BOLD) // Should be ignored since the input is empty
)
val result = input.splitByMarks(marks)
val expected = emptyList<Pair<String, List<Mark>>>()
assertEquals(expected, result)
}
@Test
fun testFullyOverlappingMarks() {
val input = "Overlap test"
val marks = listOf(
Mark(0..11, Mark.Type.BOLD), // Full range
Mark(0..11, Mark.Type.ITALIC) // Fully overlaps with another style
)
val result = input.splitByMarks(marks)
val expected = listOf(
"Overlap test" to marks
)
assertEquals(expected, result)
}
@Test
fun testNestedMarks() {
val input = "Nested styles"
val marks = listOf(
Mark(0..5, Mark.Type.BOLD), // "Nested" styled as bold
Mark(4..13, Mark.Type.ITALIC) // Overlaps "ed styles" with italic
)
val result = input.splitByMarks(marks)
val expected = listOf(
"Nest" to listOf(marks[0]),
"ed" to listOf(marks[0], marks[1]),
" styles" to listOf(marks[1])
)
assertEquals(expected, result)
}
}

View file

@ -1,12 +1,13 @@
package com.anytypeio.anytype.feature_discussions.presentation
import com.anytypeio.anytype.core_models.Block
import com.anytypeio.anytype.core_models.Hash
import com.anytypeio.anytype.core_models.chats.Chat
sealed interface DiscussionView {
data class Message(
val id: String,
val content: String,
val content: List<Content.Part>,
val author: String,
val timestamp: Long,
val attachments: List<Chat.Message.Attachment> = emptyList(),
@ -15,6 +16,20 @@ sealed interface DiscussionView {
val isEdited: Boolean = false,
val avatar: Avatar = Avatar.Initials()
) : DiscussionView {
interface Content {
data class Part(
val part: String,
val styles: List<Block.Content.Text.Mark> = emptyList()
) : Content {
val isBold: Boolean = styles.any { it.type == Block.Content.Text.Mark.Type.BOLD }
val isItalic: Boolean = styles.any { it.type == Block.Content.Text.Mark.Type.ITALIC }
val isStrike = styles.any { it.type == Block.Content.Text.Mark.Type.STRIKETHROUGH }
val underline = styles.any { it.type == Block.Content.Text.Mark.Type.UNDERLINE }
val link = styles.find { it.type == Block.Content.Text.Mark.Type.LINK }
}
}
data class Reaction(
val emoji: String,
val count: Int,

View file

@ -7,7 +7,7 @@ import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.primitives.Space
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_ui.text.splitByMarks
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.base.fold
import com.anytypeio.anytype.domain.base.onFailure
@ -114,14 +114,24 @@ class DiscussionViewModel @Inject constructor(
is Store.Data -> type.members.find { member ->
member.identity == msg.creator
}
is Store.Empty -> null
}
}
val content = msg.content
DiscussionView.Message(
id = msg.id,
timestamp = msg.createdAt * 1000,
content = msg.content?.text.orEmpty(),
content = content?.text
.orEmpty()
.splitByMarks(marks = content?.marks.orEmpty())
.map { (part, styles) ->
DiscussionView.Message.Content.Part(
part = part,
styles = styles
)
},
author = member?.name ?: msg.creator.takeLast(5),
isUserAuthor = msg.creator == account,
isEdited = msg.modifiedAt > msg.createdAt,

View file

@ -19,19 +19,31 @@ fun DiscussionPreview() {
messages = listOf(
DiscussionView.Message(
id = "1",
content = stringResource(id = R.string.default_text_placeholder),
content = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
),
author = "Walter",
timestamp = System.currentTimeMillis()
),
DiscussionView.Message(
id = "2",
content = stringResource(id = R.string.default_text_placeholder),
content = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
),
author = "Leo",
timestamp = System.currentTimeMillis()
),
DiscussionView.Message(
id = "3",
content = stringResource(id = R.string.default_text_placeholder),
content = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
),
author = "Gilbert",
timestamp = System.currentTimeMillis()
)
@ -44,7 +56,10 @@ fun DiscussionPreview() {
onDeleteMessage = {},
onCopyMessage = {},
onAttachmentClicked = {},
onEditMessage = {}
onEditMessage = {},
onMarkupLinkClicked = {
}
)
}
@ -59,7 +74,11 @@ fun DiscussionScreenPreview() {
add(
DiscussionView.Message(
id = idx.toString(),
content = stringResource(id = R.string.default_text_placeholder),
content = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
),
author = "User ${idx.inc()}",
timestamp =
System.currentTimeMillis()
@ -82,7 +101,8 @@ fun DiscussionScreenPreview() {
onEditMessage = {},
onExitEditMessageMode = {},
isSpaceLevelChat = true,
onBackButtonClicked = {}
onBackButtonClicked = {},
onMarkupLinkClicked = {}
)
}
@ -92,13 +112,18 @@ fun DiscussionScreenPreview() {
fun BubblePreview() {
Bubble(
name = "Leo Marx",
msg = stringResource(id = R.string.default_text_placeholder),
msg = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
),
timestamp = System.currentTimeMillis(),
onReacted = {},
onDeleteMessage = {},
onCopyMessage = {},
onAttachmentClicked = {},
onEditMessage = {}
onEditMessage = {},
onMarkupLinkClicked = {}
)
}
@ -108,14 +133,19 @@ fun BubblePreview() {
fun BubbleEditedPreview() {
Bubble(
name = "Leo Marx",
msg = stringResource(id = R.string.default_text_placeholder),
msg = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
),
isEdited = true,
timestamp = System.currentTimeMillis(),
onReacted = {},
onDeleteMessage = {},
onCopyMessage = {},
onAttachmentClicked = {},
onEditMessage = {}
onEditMessage = {},
onMarkupLinkClicked = {}
)
}
@ -125,7 +155,11 @@ fun BubbleEditedPreview() {
fun BubbleWithAttachmentPreview() {
Bubble(
name = "Leo Marx",
msg = stringResource(id = R.string.default_text_placeholder),
msg = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
),
timestamp = System.currentTimeMillis(),
onReacted = {},
onDeleteMessage = {},
@ -139,6 +173,7 @@ fun BubbleWithAttachmentPreview() {
)
},
onAttachmentClicked = {},
onEditMessage = {}
onEditMessage = {},
onMarkupLinkClicked = {}
)
}

View file

@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@ -27,9 +26,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
@ -72,16 +69,21 @@ 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.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
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.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
@ -127,7 +129,8 @@ fun DiscussionScreenWrapper(
vm: DiscussionViewModel,
// TODO move to view model
onAttachClicked: () -> Unit,
onBackButtonClicked: () -> Unit
onBackButtonClicked: () -> Unit,
onMarkupLinkClicked: (String) -> Unit
) {
NavHost(
navController = rememberNavController(),
@ -164,7 +167,7 @@ fun DiscussionScreenWrapper(
onReacted = vm::onReacted,
onCopyMessage = { msg ->
clipboard.setText(
AnnotatedString(text = msg.content)
AnnotatedString(text = msg.content.joinToString())
)
},
onDeleteMessage = vm::onDeleteMessage,
@ -172,7 +175,8 @@ fun DiscussionScreenWrapper(
onAttachmentClicked = vm::onAttachmentClicked,
isInEditMessageMode = vm.chatBoxMode.collectAsState().value is ChatBoxMode.EditMessage,
onExitEditMessageMode = vm::onExitEditMessageMode,
onBackButtonClicked = onBackButtonClicked
onBackButtonClicked = onBackButtonClicked,
onMarkupLinkClicked = onMarkupLinkClicked
)
LaunchedEffect(Unit) {
vm.commands.collect { command ->
@ -212,7 +216,8 @@ fun DiscussionScreen(
onCopyMessage: (DiscussionView.Message) -> Unit,
onEditMessage: (DiscussionView.Message) -> Unit,
onAttachmentClicked: (Chat.Message.Attachment) -> Unit,
onExitEditMessageMode: () -> Unit
onExitEditMessageMode: () -> Unit,
onMarkupLinkClicked: (String) -> Unit
) {
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
@ -258,12 +263,13 @@ fun DiscussionScreen(
onEditMessage = { msg ->
onEditMessage(msg).also {
textState = TextFieldValue(
msg.content,
selection = TextRange(msg.content.length)
msg.content.joinToString(),
selection = TextRange(msg.content.joinToString().length)
)
chatBoxFocusRequester.requestFocus()
}
}
},
onMarkupLinkClicked = onMarkupLinkClicked
)
// Jump to bottom button shows up when user scrolls past a threshold.
// Convert to pixels:
@ -282,7 +288,7 @@ fun DiscussionScreen(
GoToBottomButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.align(Alignment.BottomCenter)
.padding(end = 12.dp),
onGoToBottomClicked = {
scope.launch {
@ -790,7 +796,8 @@ fun Messages(
onDeleteMessage: (DiscussionView.Message) -> Unit,
onCopyMessage: (DiscussionView.Message) -> Unit,
onAttachmentClicked: (Chat.Message.Attachment) -> Unit,
onEditMessage: (DiscussionView.Message) -> Unit
onEditMessage: (DiscussionView.Message) -> Unit,
onMarkupLinkClicked: (String) -> Unit
) {
LazyColumn(
modifier = modifier,
@ -839,7 +846,8 @@ fun Messages(
onAttachmentClicked = onAttachmentClicked,
onEditMessage = {
onEditMessage(msg)
}
},
onMarkupLinkClicked = onMarkupLinkClicked
)
if (msg.isUserAuthor) {
Spacer(modifier = Modifier.width(8.dp))
@ -957,7 +965,7 @@ val userMessageBubbleColor = Color(0x66000000)
fun Bubble(
modifier: Modifier = Modifier,
name: String,
msg: String,
msg: List<DiscussionView.Message.Content.Part>,
timestamp: Long,
attachments: List<Chat.Message.Attachment> = emptyList(),
isUserAuthor: Boolean = false,
@ -967,7 +975,8 @@ fun Bubble(
onDeleteMessage: () -> Unit,
onCopyMessage: () -> Unit,
onEditMessage: () -> Unit,
onAttachmentClicked: (Chat.Message.Attachment) -> Unit
onAttachmentClicked: (Chat.Message.Attachment) -> Unit,
onMarkupLinkClicked: (String) -> Unit
) {
var showDropdownMenu by remember { mutableStateOf(false) }
Column(
@ -1014,48 +1023,70 @@ fun Bubble(
maxLines = 1
)
}
if (isEdited) {
Text(
modifier = Modifier.padding(
top = 0.dp,
start = 16.dp,
end = 16.dp,
bottom = 0.dp
),
text = buildAnnotatedString {
append(msg)
withStyle(
style = SpanStyle(
color = if (isUserAuthor)
colorResource(id = R.color.text_white)
else
colorResource(id = R.color.text_primary),
)
) {
append(
" (${stringResource(R.string.chats_message_edited)})"
)
Text(
modifier = Modifier.padding(
top = 0.dp,
start = 16.dp,
end = 16.dp,
bottom = 0.dp
),
text = buildAnnotatedString {
msg.forEach { part ->
if (part.link != null && part.link.param != null) {
withLink(
LinkAnnotation.Clickable(
tag = "link",
styles = TextLinkStyles(
style = SpanStyle(
fontWeight = if (part.isBold) FontWeight.Bold else null,
fontStyle = if (part.isItalic) FontStyle.Italic else null,
textDecoration = TextDecoration.Underline
)
)
) {
onMarkupLinkClicked(part.link.param.orEmpty())
}
) {
append(part.part)
}
} else {
withStyle(
style = SpanStyle(
fontWeight = if (part.isBold) FontWeight.Bold else null,
fontStyle = if (part.isItalic) FontStyle.Italic else null,
textDecoration = if (part.underline)
TextDecoration.Underline
else if (part.isStrike)
TextDecoration.LineThrough
else null,
)
) {
append(part.part)
}
}
},
style = BodyRegular,
color = colorResource(id = R.color.text_primary)
)
} else {
Text(
modifier = Modifier.padding(
top = 0.dp,
start = 16.dp,
end = 16.dp,
bottom = 0.dp
),
text = msg,
style = BodyRegular,
color = if (isUserAuthor)
colorResource(id = R.color.text_white)
else
colorResource(id = R.color.text_primary),
)
}
if (isEdited) {
withStyle(
style = SpanStyle(
color = if (isUserAuthor)
colorResource(id = R.color.text_white)
else
colorResource(id = R.color.text_primary),
)
) {
append(
" (${stringResource(R.string.chats_message_edited)})"
)
}
}
}
},
style = BodyRegular,
color = if (isUserAuthor)
colorResource(id = R.color.text_white)
else
colorResource(id = R.color.text_primary),
)
attachments.forEach { attachment ->
Attachment(
modifier = Modifier.padding(
@ -1329,12 +1360,7 @@ fun GoToBottomButton(
.offset(x = 0.dp, y = -bottomOffset)
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.border(
width = 1.dp,
color = colorResource(id = R.color.shape_primary),
shape = RoundedCornerShape(12.dp)
)
.background(color = colorResource(id = R.color.background_primary))
.background(color = colorResource(id = R.color.navigation_panel))
.clickable {
onGoToBottomClicked()
}