diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/discussions/DiscussionFragment.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/discussions/DiscussionFragment.kt index c344454485..d5d7e6ee48 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/discussions/DiscussionFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/discussions/DiscussionFragment.kt @@ -78,6 +78,9 @@ class DiscussionFragment : BaseComposeFragment() { }, onBackButtonClicked = { // TODO + }, + onMarkupLinkClicked = { + } ) diff --git a/app/src/main/java/com/anytypeio/anytype/ui/home/HomeScreenFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/home/HomeScreenFragment.kt index 169fbdcfac..103de25c68 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/home/HomeScreenFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/home/HomeScreenFragment.kt @@ -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) + ) } ) } diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/text/Markup.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/text/Markup.kt new file mode 100644 index 0000000000..2b55a65337 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/text/Markup.kt @@ -0,0 +1,46 @@ +package com.anytypeio.anytype.core_ui.text + +import com.anytypeio.anytype.core_models.Block + +fun String.splitByMarks( + marks: List +) : List>> { + + if (this.isEmpty()) return emptyList() + + // Create a map to track styles applied to each character index + val styleMap = mutableMapOf>() + + // 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>>() + 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 +} \ No newline at end of file diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/text/MarkupSplitTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/text/MarkupSplitTest.kt new file mode 100644 index 0000000000..25d1d984a3 --- /dev/null +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/text/MarkupSplitTest.kt @@ -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() + val result = input.splitByMarks(marks) + val expected = listOf(input to emptyList()) + 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>>() + 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) + } +} \ No newline at end of file diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt index 281097517f..0d98f8921d 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt @@ -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, val author: String, val timestamp: Long, val attachments: List = 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 = 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, diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt index be166d9b4b..673edae6ea 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt @@ -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, diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionPreviews.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionPreviews.kt index 951b8a0708..b2f13149ec 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionPreviews.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionPreviews.kt @@ -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 = {} ) } \ No newline at end of file diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt index 0d2497f148..5a0b109667 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt @@ -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, timestamp: Long, attachments: List = 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() }