From 892249143df1dad426c4d574c60ff9fd2e72bc8b Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:07:25 +0000 Subject: [PATCH] revert --- .../chat/simplex/common/model/ChatModel.kt | 22 +- .../common/views/chat/ChatItemsLoader.kt | 8 +- .../common/views/chat/ChatItemsMerger.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 23 +- .../common/views/chat/TextSelection.kt | 311 ------------------ .../common/views/chat/item/EmojiItemView.kt | 37 +-- .../common/views/chat/item/FramedItemView.kt | 70 +--- .../common/views/chat/item/TextItemView.kt | 311 +++++++----------- .../views/usersettings/MarkdownHelpView.kt | 10 +- 9 files changed, 154 insertions(+), 642 deletions(-) delete mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 60e5a4e822..59fd45f3d7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -4379,24 +4379,28 @@ sealed class Format { @Serializable @SerialName("phone") class Phone: Format() @Serializable @SerialName("unknown") class Unknown: Format() - fun style(colors: Colors, typography: Typography): SpanStyle = when (this) { + val style: SpanStyle @Composable get() = when (this) { is Bold -> SpanStyle(fontWeight = FontWeight.Bold) is Italic -> SpanStyle(fontStyle = FontStyle.Italic) is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough) is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace) is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor) - is Small -> SpanStyle(fontSize = typography.body2.fontSize, color = colors.secondary) - is Colored -> SpanStyle(color = this.color.uiColor(colors)) - is Uri, is HyperLink, is SimplexLink, is Email, is Phone -> linkStyle(colors) - is Command -> SpanStyle(color = colors.primary, fontFamily = FontFamily.Monospace) + is Small -> SpanStyle(fontSize = MaterialTheme.typography.body2.fontSize, color = MaterialTheme.colors.secondary) + is Colored -> SpanStyle(color = this.color.uiColor) + is Uri -> linkStyle + is HyperLink -> linkStyle + is SimplexLink -> linkStyle + is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace) is Mention -> SpanStyle(fontWeight = FontWeight.Medium) + is Email -> linkStyle + is Phone -> linkStyle is Unknown -> SpanStyle() } val isSimplexLink = this is SimplexLink companion object { - fun linkStyle(colors: Colors) = SpanStyle(color = colors.primary, textDecoration = TextDecoration.Underline) + val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline) } } @@ -4428,15 +4432,15 @@ enum class FormatColor(val color: String) { black("black"), white("white"); - fun uiColor(colors: Colors): Color = when (this) { + val uiColor: Color @Composable get() = when (this) { red -> Color.Red green -> SimplexGreen blue -> SimplexBlue yellow -> WarningYellow cyan -> Color.Cyan magenta -> Color.Magenta - black -> colors.onBackground - white -> colors.onBackground + black -> MaterialTheme.colors.onBackground + white -> MaterialTheme.colors.onBackground } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 6562d40cec..107d427556 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -88,8 +88,7 @@ suspend fun processLoadedChat( val (newIds, _) = mapItemsToIds(chat.chatItems) val wasSize = newItems.size val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( - unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed, - selectionActive = chatState.selectionActive + unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) @@ -178,14 +177,13 @@ private fun removeDuplicatesAndModifySplitsOnBeforePagination( newItems: SnapshotStateList, newIds: Set, splits: StateFlow>, - visibleItemIndexesNonReversed: () -> IntRange, - selectionActive: Boolean = false + visibleItemIndexesNonReversed: () -> IntRange ): ModifiedSplits { var oldUnreadSplitIndex: Int = -1 var newUnreadSplitIndex: Int = -1 val visibleItemIndexes = visibleItemIndexesNonReversed() var lastSplitIndexTrimmed = -1 - var allowedTrimming = !selectionActive + var allowedTrimming = true var index = 0 /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */ val trimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT .. newItems.size - TRIM_KEEP_COUNT diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index 8e7cde48e3..d98c041478 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -202,9 +202,7 @@ data class ActiveChatState ( // exclusive val unreadAfter: MutableStateFlow = MutableStateFlow(0), // exclusive - val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0), - // Desktop text selection: disable item eviction during active selection - @Volatile var selectionActive: Boolean = false + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) ) { fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List) { toItemId ?: return diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 30cfbc6ff0..5206d0556c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.* import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.input.key.* import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.layout.layoutId @@ -963,21 +962,11 @@ fun ChatLayout( val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chat != null) { - val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null - if (selectionManager != null) { - LaunchedEffect(selectionManager) { - snapshotFlow { selectionManager.selectionState != SelectionState.Idle } - .collect { chatsCtx.chatState.selectionActive = it } - } - } Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble - CompositionLocalProvider( - LocalSelectionManager provides selectionManager, - LocalBringIntoViewSpec provides object : BringIntoViewSpec { - override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f - } - ) { + CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f + }) { ChatItemsList( chatsCtx, remoteHostId, chat, unreadCount, composeState, composeViewHeight, searchValue, useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, @@ -1904,7 +1893,7 @@ fun BoxScope.ChatItemsList( } false } - val swipeableModifier = if (appPlatform.isDesktop) Modifier else SwipeToDismissModifier( + val swipeableModifier = SwipeToDismissModifier( state = dismissState, directions = setOf(DismissDirection.EndToStart), swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, @@ -2205,10 +2194,8 @@ fun BoxScope.ChatItemsList( } } - val selectionModifier = SelectionHandler(LocalSelectionManager.current, listState, mergedItems, linkMode) - LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter).then(selectionModifier), + Modifier.align(Alignment.BottomCenter), state = listState.value, contentPadding = PaddingValues( top = topPaddingToContent, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt deleted file mode 100644 index bac199c6d1..0000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ /dev/null @@ -1,311 +0,0 @@ -package chat.simplex.common.views.chat - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.* -import androidx.compose.ui.input.pointer.* -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.unit.dp -import chat.simplex.common.model.* -import chat.simplex.common.platform.appPlatform -import chat.simplex.common.views.chat.item.buildMsgAnnotatedString -import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR -import dev.icerock.moko.resources.compose.painterResource -import kotlinx.coroutines.* - -val SelectionHighlightColor = Color(0x4D0066FF) - -data class SelectionRange( - val startIndex: Int, - val startOffset: Int, - val endIndex: Int, - val endOffset: Int -) - -enum class SelectionState { Idle, Selecting, Selected } - -class SelectionManager { - var selectionState by mutableStateOf(SelectionState.Idle) - private set - - var range by mutableStateOf(null) - private set - - var focusWindowY by mutableStateOf(0f) - var focusWindowX by mutableStateOf(0f) - - fun startSelection(startIndex: Int) { - range = SelectionRange(startIndex, 0, startIndex, 0) - selectionState = SelectionState.Selecting - } - - fun setAnchorOffset(offset: Int) { - val r = range ?: return - range = r.copy(startOffset = offset, endOffset = offset) - } - - fun updateFocusIndex(index: Int) { - val r = range ?: return - range = r.copy(endIndex = index) - } - - fun updateFocusOffset(offset: Int) { - val r = range ?: return - range = r.copy(endOffset = offset) - } - - fun endSelection() { - selectionState = SelectionState.Selected - } - - fun clearSelection() { - range = null - selectionState = SelectionState.Idle - } - - fun computeHighlightRange(index: Int): IntRange? { - val r = range ?: return null - val lo = minOf(r.startIndex, r.endIndex) - val hi = maxOf(r.startIndex, r.endIndex) - if (index < lo || index > hi) return null - val forward = r.startIndex <= r.endIndex - val startOff = if (forward) r.startOffset else r.endOffset - val endOff = if (forward) r.endOffset else r.startOffset - return when { - index == lo && index == hi -> minOf(startOff, endOff) until maxOf(startOff, endOff) - index == lo -> startOff until Int.MAX_VALUE - index == hi -> 0 until endOff - else -> 0 until Int.MAX_VALUE - } - } - - fun getSelectedText( - items: List, - linkMode: SimplexLinkMode - ): String { - val r = range ?: return "" - val lo = minOf(r.startIndex, r.endIndex) - val hi = maxOf(r.startIndex, r.endIndex) - val forward = r.startIndex <= r.endIndex - val startOff = if (forward) r.startOffset else r.endOffset - val endOff = if (forward) r.endOffset else r.startOffset - return (lo..hi).mapNotNull { idx -> - val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null - val text = buildMsgAnnotatedString( - text = ci.text, formattedText = ci.formattedText, - sender = null, senderBold = false, prefix = null, - mentions = ci.mentions, userMemberId = null, - toggleSecrets = false, sendCommandMsg = false, linkMode = linkMode - ).text - when { - idx == lo && idx == hi -> text.substring( - startOff.coerceAtMost(text.length), - endOff.coerceAtMost(text.length) - ) - idx == lo -> text.substring(startOff.coerceAtMost(text.length)) - idx == hi -> text.substring(0, endOff.coerceAtMost(text.length)) - else -> text - } - }.joinToString("\n") - } -} - -val LocalSelectionManager = staticCompositionLocalOf { null } - - -private const val AUTO_SCROLL_ZONE_PX = 40f -private const val MIN_SCROLL_SPEED = 2f -private const val MAX_SCROLL_SPEED = 20f - -@Composable -fun BoxScope.SelectionHandler( - manager: SelectionManager?, - listState: State, - mergedItems: State, - linkMode: SimplexLinkMode -): Modifier { - if (manager == null || !appPlatform.isDesktop) return Modifier - - val touchSlop = LocalViewConfiguration.current.touchSlop - val clipboard = LocalClipboardManager.current - val focusRequester = remember { FocusRequester() } - var positionInWindow by remember { mutableStateOf(Offset.Zero) } - var viewportTop by remember { mutableStateOf(0f) } - var viewportBottom by remember { mutableStateOf(0f) } - val scope = rememberCoroutineScope() - var autoScrollJob by remember { mutableStateOf(null) } - - // Re-evaluate focus index on scroll during active drag - LaunchedEffect(manager) { - snapshotFlow { listState.value.firstVisibleItemScrollOffset } - .collect { - if (manager.selectionState == SelectionState.Selecting) { - val idx = resolveIndexAtY(listState.value, manager.focusWindowY - positionInWindow.y) - if (idx != null) manager.updateFocusIndex(idx) - } - } - } - - // Copy button - if (manager.selectionState == SelectionState.Selected) { - SelectionCopyButton( - onCopy = { - clipboard.setText(AnnotatedString(manager.getSelectedText(mergedItems.value.items, linkMode))) - } - ) - } - - return Modifier - .focusRequester(focusRequester) - .focusable() - .onKeyEvent { event -> - if (manager.selectionState == SelectionState.Selected - && (event.isCtrlPressed || event.isMetaPressed) - && event.key == Key.C - && event.type == KeyEventType.KeyDown - ) { - clipboard.setText(AnnotatedString(manager.getSelectedText(mergedItems.value.items, linkMode))) - true - } else false - } - .onGloballyPositioned { - positionInWindow = it.positionInWindow() - val bounds = it.boundsInWindow() - viewportTop = bounds.top - viewportBottom = bounds.bottom - } - .pointerInput(manager) { - awaitEachGesture { - val down = awaitPointerEvent(PointerEventPass.Initial) - val firstChange = down.changes.first() - if (!firstChange.pressed) return@awaitEachGesture - - val wasSelected = manager.selectionState == SelectionState.Selected - if (wasSelected) firstChange.consume() - - val localStart = firstChange.position - val windowStart = localStart + positionInWindow - var totalDrag = Offset.Zero - var isDragging = false - - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - val change = event.changes.first() - - if (!change.pressed) { - autoScrollJob?.cancel() - autoScrollJob = null - if (isDragging) { - manager.endSelection() - } else if (wasSelected) { - manager.clearSelection() - } - break - } - - totalDrag += change.positionChange() - - if (!isDragging && totalDrag.getDistance() > touchSlop) { - isDragging = true - val localY = firstChange.position.y - val idx = resolveIndexAtY(listState.value, localY) - if (idx != null) { - manager.startSelection(idx) - manager.focusWindowY = windowStart.y - manager.focusWindowX = windowStart.x - } - try { focusRequester.requestFocus() } catch (_: Exception) {} - change.consume() - } - - if (isDragging) { - val windowPos = change.position + positionInWindow - manager.focusWindowY = windowPos.y - manager.focusWindowX = windowPos.x - - val localY = change.position.y - val idx = resolveIndexAtY(listState.value, localY) - if (idx != null) manager.updateFocusIndex(idx) - - change.consume() - - // Auto-scroll: direction-aware - val draggingDown = windowPos.y > windowStart.y - val edgeDistance = if (draggingDown) { - viewportBottom - windowPos.y - } else { - windowPos.y - viewportTop - } - val shouldAutoScroll = edgeDistance in 0f..AUTO_SCROLL_ZONE_PX - - if (shouldAutoScroll && autoScrollJob?.isActive != true) { - autoScrollJob = scope.launch { - while (isActive && manager.selectionState == SelectionState.Selecting) { - val curEdge = if (draggingDown) { - viewportBottom - manager.focusWindowY - } else { - manager.focusWindowY - viewportTop - } - if (curEdge >= AUTO_SCROLL_ZONE_PX) break - - val fraction = 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f) - val speed = MIN_SCROLL_SPEED + (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED) * fraction - listState.value.scrollBy(if (draggingDown) -speed else speed) - delay(16) - } - } - } else if (!shouldAutoScroll) { - autoScrollJob?.cancel() - autoScrollJob = null - } - } - } - } - } -} - -private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { - val visibleItems = listState.layoutInfo.visibleItemsInfo - return visibleItems.find { item -> - localY >= item.offset && localY < item.offset + item.size - }?.index -} - -@Composable -private fun BoxScope.SelectionCopyButton(onCopy: () -> Unit) { - Row( - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 8.dp) - .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) - .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) - .clickable { onCopy() } - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(painterResource(MR.images.ic_content_copy), null, Modifier.size(16.dp), tint = MaterialTheme.colors.primary) - Spacer(Modifier.width(6.dp)) - Text(generalGetString(MR.strings.copy_verb), color = MaterialTheme.colors.primary) - } -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index 4e62d0bf55..7aca0466f9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -1,10 +1,9 @@ package chat.simplex.common.views.chat.item -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle @@ -13,46 +12,18 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MREmojiChar import chat.simplex.common.ui.theme.EmojiFont -import chat.simplex.common.views.chat.* import java.sql.Timestamp val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont) val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont) @Composable -fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean, selectionIndex: Int = -1) { - val selectionManager = LocalSelectionManager.current - val emojiText = chatItem.content.text.trim() - - if (selectionManager != null && selectionIndex >= 0) { - val isAnchor = remember(selectionIndex) { - derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } - } - LaunchedEffect(isAnchor.value) { - if (!isAnchor.value) return@LaunchedEffect - selectionManager.setAnchorOffset(0) - } - - val isFocus = remember(selectionIndex) { - derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } - } - if (isFocus.value) { - LaunchedEffect(Unit) { - snapshotFlow { selectionManager.focusWindowY } - .collect { selectionManager.updateFocusOffset(emojiText.length) } - } - } - } - - val isSelected = selectionManager?.computeHighlightRange(selectionIndex) != null - +fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) { Column( Modifier.padding(vertical = 8.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Box(if (isSelected) Modifier.background(SelectionHighlightColor) else Modifier) { - EmojiText(chatItem.content.text) - } + EmojiText(chatItem.content.text) CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 6a4d0e94f1..10cb86fceb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -20,10 +20,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers @@ -368,65 +366,11 @@ fun CIMarkdownText( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean, showTimestamp: Boolean, - prefix: AnnotatedString? = null, - selectionIndex: Int = -1 + prefix: AnnotatedString? = null ) { - val selectionManager = LocalSelectionManager.current - val boundsState = remember { mutableStateOf(null) } - val layoutResultState = remember { mutableStateOf(null) } - val chatInfo = chat.chatInfo - val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text - - val contentLength = remember(text, ci.formattedText, ci.mentions) { - buildMsgAnnotatedString( - text = text, formattedText = if (text.isEmpty()) emptyList() else ci.formattedText, - sender = null, senderBold = true, prefix = prefix, - mentions = ci.mentions, userMemberId = when { - chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId - else -> null - }, - toggleSecrets = true, sendCommandMsg = chatInfo.useCommands && chat.chatInfo.sndReady, - linkMode = linkMode - ).text.length - } - - if (selectionManager != null && ci.meta.isLive != true && selectionIndex >= 0) { - val isAnchor = remember(selectionIndex) { - derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } - } - LaunchedEffect(isAnchor.value) { - if (!isAnchor.value) return@LaunchedEffect - val bounds = boundsState.value ?: return@LaunchedEffect - val layout = layoutResultState.value ?: return@LaunchedEffect - val offset = layout.getOffsetForPosition( - Offset(selectionManager.focusWindowX - bounds.left, selectionManager.focusWindowY - bounds.top) - ) - selectionManager.setAnchorOffset(offset.coerceAtMost(contentLength)) - } - - val isFocus = remember(selectionIndex) { - derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } - } - if (isFocus.value) { - LaunchedEffect(Unit) { - snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX } - .collect { (py, px) -> - val bounds = boundsState.value ?: return@collect - val layout = layoutResultState.value ?: return@collect - val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) - selectionManager.updateFocusOffset(offset.coerceAtMost(contentLength)) - } - } - } - } - - val highlightRange = selectionManager?.computeHighlightRange(selectionIndex) - - Box( - Modifier - .padding(vertical = 7.dp, horizontal = 12.dp) - .onGloballyPositioned { boundsState.value = it.boundsInWindow() } - ) { + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { + val chatInfo = chat.chatInfo + val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, @@ -435,9 +379,7 @@ fun CIMarkdownText( chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null }, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, - selectionRange = highlightRange, - onTextLayoutResult = { layoutResultState.value = it } + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index c39dcd5914..3984e5bc40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -5,12 +5,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.material.* +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.* @@ -23,7 +22,6 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors -import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* import kotlinx.coroutines.* @@ -57,123 +55,6 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) -fun buildMsgAnnotatedString( - text: CharSequence, - formattedText: List?, - sender: String?, - senderBold: Boolean, - prefix: AnnotatedString?, - mentions: Map?, - userMemberId: String?, - toggleSecrets: Boolean, - showSecrets: Map = emptyMap(), - sendCommandMsg: Boolean, - linkMode: SimplexLinkMode, - colors: Colors? = null, - typography: Typography? = null -): AnnotatedString = buildAnnotatedString { - fun styled(format: Format, content: () -> Unit) { - val s = if (colors != null && typography != null) format.style(colors, typography) else null - if (s != null) withStyle(s) { content() } else content() - } - appendSender(this, sender, senderBold) - if (prefix != null) append(prefix) - if (formattedText == null) { - if (text is String) append(text) - else if (text is AnnotatedString) append(text) - } else { - for ((i, ft) in formattedText.withIndex()) { - if (ft.format == null) append(ft.text) - else when(ft.format) { - is Format.Bold, is Format.Italic, is Format.StrikeThrough, is Format.Snippet, - is Format.Small, is Format.Colored -> styled(ft.format) { append(ft.text) } - is Format.Secret -> { - if (toggleSecrets) { - val key = i.toString() - withAnnotation(tag = "SECRET", annotation = key) { - if (showSecrets[key] == true) append(ft.text) else styled(ft.format) { append(ft.text) } - } - } else { - styled(ft.format) { append(ft.text) } - } - } - is Format.Mention -> { - val mention = mentions?.get(ft.format.memberName) - if (mention != null) { - if (mention.memberRef != null) { - val displayName = mention.memberRef.displayName - val name = if (mention.memberRef.localAlias.isNullOrEmpty()) { - displayName - } else { - "${mention.memberRef.localAlias} ($displayName)" - } - val ftStyle = if (colors != null && typography != null) ft.format.style(colors, typography) else null - val mentionStyle = if (ftStyle != null && colors != null && mention.memberId == userMemberId) ftStyle.copy(color = colors.primary) else ftStyle - if (mentionStyle != null) withStyle(mentionStyle) { append(mentionText(name)) } else append(mentionText(name)) - } else { - styled(ft.format) { append(mentionText(ft.format.memberName)) } - } - } else { - append(ft.text) - } - } - is Format.Command -> - if (!sendCommandMsg) { - append(ft.text) - } else { - val cmd = ft.format.commandStr - withAnnotation(tag = "COMMAND", annotation = cmd) { - styled(ft.format) { append("/$cmd") } - } - } - is Format.Uri -> { - val s = ft.text - val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s" - withAnnotation(tag = "WEB_URL", annotation = link) { - styled(ft.format) { append(ft.text) } - } - } - is Format.HyperLink -> { - withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) { - styled(ft.format) { append(ft.format.showText ?: ft.text) } - } - } - is Format.SimplexLink -> { - val link = - if (linkMode == SimplexLinkMode.BROWSER && ft.format.showText == null && !ft.text.startsWith("[")) ft.text - else ft.format.simplexUri - val t = ft.format.showText ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null - withAnnotation(tag = "SIMPLEX_URL", annotation = link) { - if (t == null) { - styled(ft.format) { append(ft.text) } - } else { - val ftStyle = if (colors != null && typography != null) ft.format.style(colors, typography) else null - if (ftStyle != null) { - withStyle(ftStyle) { append("$t ") } - withStyle(ftStyle.copy(fontStyle = FontStyle.Italic)) { append(ft.format.viaHosts) } - } else { - append("$t ") - append(ft.format.viaHosts) - } - } - } - } - is Format.Email -> { - withAnnotation(tag = "OTHER_URL", annotation = "mailto:${ft.text}") { - styled(ft.format) { append(ft.text) } - } - } - is Format.Phone -> { - withAnnotation(tag = "OTHER_URL", annotation = "tel:${ft.text}") { - styled(ft.format) { append(ft.text) } - } - } - is Format.Unknown -> append(ft.text) - } - } - } -} - @Composable fun MarkdownText ( text: CharSequence, @@ -196,9 +77,7 @@ fun MarkdownText ( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, showTimestamp: Boolean = true, - prefix: AnnotatedString? = null, - selectionRange: IntRange? = null, - onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null + prefix: AnnotatedString? = null ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -246,38 +125,128 @@ fun MarkdownText ( } ) } - val contentAnnotated = buildMsgAnnotatedString( - text = text, formattedText = formattedText, sender = sender, senderBold = senderBold, - prefix = prefix, mentions = mentions, userMemberId = userMemberId, - toggleSecrets = toggleSecrets, showSecrets = showSecrets, - sendCommandMsg = sendCommandMsg != null, linkMode = linkMode, - colors = MaterialTheme.colors, typography = MaterialTheme.typography - ) - val contentLength = contentAnnotated.text.length - val clampedSelectionRange = selectionRange?.let { - it.first until minOf(it.last, contentLength) - } if (formattedText == null) { val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) - append(contentAnnotated) + appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) + if (text is String) append(text) + else if (text is AnnotatedString) append(text) if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - if (meta?.isLive == true) { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) - } else { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedSelectionRange, onTextLayoutResult = onTextLayoutResult) - } + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } else { - val hasLinks = formattedText.any { it.format is Format.Uri || it.format is Format.HyperLink || it.format is Format.SimplexLink || it.format is Format.Email || it.format is Format.Phone } - val hasSecrets = toggleSecrets && formattedText.any { it.format is Format.Secret } - val hasCommands = sendCommandMsg != null && formattedText.any { it.format is Format.Command } + var hasLinks = false + var hasSecrets = false + var hasCommands = false val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) - append(contentAnnotated) + appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) + for ((i, ft) in formattedText.withIndex()) { + if (ft.format == null) append(ft.text) + else when(ft.format) { + is Format.Bold -> withStyle(ft.format.style) { append(ft.text) } + is Format.Italic -> withStyle(ft.format.style) { append(ft.text) } + is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) } + is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) } + is Format.Small -> withStyle(ft.format.style) { append(ft.text) } + is Format.Colored -> withStyle(ft.format.style) { append(ft.text) } + is Format.Secret -> { + val ftStyle = ft.format.style + if (toggleSecrets) { + hasSecrets = true + val key = i.toString() + withAnnotation(tag = "SECRET", annotation = key) { + if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } + } + } else { + withStyle(ftStyle) { append(ft.text) } + } + } + is Format.Mention -> { + val mention = mentions?.get(ft.format.memberName) + if (mention != null) { + val ftStyle = ft.format.style + if (mention.memberRef != null) { + val displayName = mention.memberRef.displayName + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) { + displayName + } else { + "${mention.memberRef.localAlias} ($displayName)" + } + val mentionStyle = if (mention.memberId == userMemberId) ftStyle.copy(color = MaterialTheme.colors.primary) else ftStyle + withStyle(mentionStyle) { append(mentionText(name)) } + } else { + withStyle(ftStyle) { append(mentionText(ft.format.memberName)) } + } + } else { + append(ft.text) + } + } + is Format.Command -> + if (sendCommandMsg == null) { + append(ft.text) + } else { + hasCommands = true + val ftStyle = ft.format.style + val cmd = ft.format.commandStr + withAnnotation(tag = "COMMAND", annotation = cmd) { + withStyle(ftStyle) { append("/$cmd") } + } + } + is Format.Uri -> { + hasLinks = true + val ftStyle = Format.linkStyle + val s = ft.text + val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s" + withAnnotation(tag = "WEB_URL", annotation = link) { + withStyle(ftStyle) { append(ft.text) } + } + } + is Format.HyperLink -> { + hasLinks = true + val ftStyle = Format.linkStyle + withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) { + withStyle(ftStyle) { append(ft.format.showText ?: ft.text) } + } + } + is Format.SimplexLink -> { + hasLinks = true + val ftStyle = Format.linkStyle + val link = + if (linkMode == SimplexLinkMode.BROWSER && ft.format.showText == null && !ft.text.startsWith("[")) ft.text + else ft.format.simplexUri + val t = ft.format.showText ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null + withAnnotation(tag = "SIMPLEX_URL", annotation = link) { + if (t == null) { + withStyle(ftStyle) { append(ft.text) } + } else { + withStyle(ftStyle) { append("$t ") } + withStyle(ftStyle.copy(fontStyle = FontStyle.Italic)) { append(ft.format.viaHosts) } + } + } + } + is Format.Email -> { + hasLinks = true + val ftStyle = Format.linkStyle + withAnnotation(tag = "OTHER_URL", annotation = "mailto:${ft.text}") { + withStyle(ftStyle) { append(ft.text) } + } + } + is Format.Phone -> { + hasLinks = true + val ftStyle = Format.linkStyle + withAnnotation(tag = "OTHER_URL", annotation = "tel:${ft.text}") { + withStyle(ftStyle) { append(ft.text) } + } + } + is Format.Unknown -> append(ft.text) + } + } if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } @@ -288,7 +257,7 @@ fun MarkdownText ( } if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) { val icon = remember { mutableStateOf(PointerIcon.Default) } - ClickableText(annotatedText, style = style, selectionRange = selectionRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -331,11 +300,10 @@ fun MarkdownText ( annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) - }, - onTextLayout = { onTextLayoutResult?.invoke(it) } + } ) } else { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = selectionRange, onTextLayoutResult = onTextLayoutResult) + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } } } @@ -346,7 +314,6 @@ fun ClickableText( text: AnnotatedString, modifier: Modifier = Modifier, style: TextStyle = TextStyle.Default, - selectionRange: IntRange? = null, softWrap: Boolean = true, overflow: TextOverflow = TextOverflow.Clip, maxLines: Int = Int.MAX_VALUE, @@ -387,19 +354,9 @@ fun ClickableText( } } - val selectionHighlight = if (selectionRange != null) { - Modifier.drawBehind { - layoutResult.value?.let { result -> - if (selectionRange.first < selectionRange.last && selectionRange.last + 1 <= text.length) { - drawPath(result.getPathForRange(selectionRange.first, selectionRange.last + 1), SelectionHighlightColor) - } - } - } - } else Modifier - BasicText( text = text, - modifier = modifier.then(selectionHighlight).then(pressIndicator), + modifier = modifier.then(pressIndicator), style = style, softWrap = softWrap, overflow = overflow, @@ -411,40 +368,6 @@ fun ClickableText( ) } -@Composable -private fun SelectableText( - text: AnnotatedString, - style: TextStyle, - modifier: Modifier = Modifier, - maxLines: Int = Int.MAX_VALUE, - overflow: TextOverflow = TextOverflow.Clip, - selectionRange: IntRange? = null, - onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null -) { - val layoutResult = remember { mutableStateOf(null) } - val highlight = if (selectionRange != null) { - Modifier.drawBehind { - layoutResult.value?.let { result -> - if (selectionRange.first < selectionRange.last && selectionRange.last + 1 <= text.length) { - drawPath(result.getPathForRange(selectionRange.first, selectionRange.last + 1), SelectionHighlightColor) - } - } - } - } else Modifier - - BasicText( - text = text, - modifier = modifier.then(highlight), - style = style, - maxLines = maxLines, - overflow = overflow, - onTextLayout = { - layoutResult.value = it - onTextLayoutResult?.invoke(it) - } - ) -} - fun openBrowserAlert(uri: String, uriHandler: UriHandler) { val (res, err) = sanitizeUri(uri) if (res == null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt index 5a1f8a1c44..ab2df2014c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt @@ -3,7 +3,7 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.* +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource @@ -38,7 +38,7 @@ fun MarkdownHelpView() { Row { MdSyntax("!1 $colored!") Text(buildAnnotatedString { - withStyle(Format.Colored(FormatColor.red).style(MaterialTheme.colors, MaterialTheme.typography)) { append(colored) } + withStyle(Format.Colored(FormatColor.red).style) { append(colored) } append(" (") appendColor(this, "1", FormatColor.red, ", ") appendColor(this, "2", FormatColor.green, ", ") @@ -52,7 +52,7 @@ fun MarkdownHelpView() { MdSyntax("#$secret#") SelectionContainer { Text(buildAnnotatedString { - withStyle(Format.Secret().style(MaterialTheme.colors, MaterialTheme.typography)) { append(secret) } + withStyle(Format.Secret().style) { append(secret) } }) } } @@ -72,14 +72,14 @@ fun MdFormat(markdown: String, example: String, format: Format) { Row { MdSyntax(markdown) Text(buildAnnotatedString { - withStyle(format.style(MaterialTheme.colors, MaterialTheme.typography)) { append(example) } + withStyle(format.style) { append(example) } }) } } @Composable fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: String) { - b.withStyle(Format.Colored(c).style(MaterialTheme.colors, MaterialTheme.typography)) { append(s)} + b.withStyle(Format.Colored(c).style) { append(s)} b.append(after) }