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 107d427556..6562d40cec 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,7 +88,8 @@ suspend fun processLoadedChat( val (newIds, _) = mapItemsToIds(chat.chatItems) val wasSize = newItems.size val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( - unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed + unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed, + selectionActive = chatState.selectionActive ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) @@ -177,13 +178,14 @@ private fun removeDuplicatesAndModifySplitsOnBeforePagination( newItems: SnapshotStateList, newIds: Set, splits: StateFlow>, - visibleItemIndexesNonReversed: () -> IntRange + visibleItemIndexesNonReversed: () -> IntRange, + selectionActive: Boolean = false ): ModifiedSplits { var oldUnreadSplitIndex: Int = -1 var newUnreadSplitIndex: Int = -1 val visibleItemIndexes = visibleItemIndexesNonReversed() var lastSplitIndexTrimmed = -1 - var allowedTrimming = true + var allowedTrimming = !selectionActive 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 d98c041478..f9d32892ee 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,7 +202,8 @@ data class ActiveChatState ( // exclusive val unreadAfter: MutableStateFlow = MutableStateFlow(0), // exclusive - val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0), + @Volatile var selectionActive: Boolean = false ) { 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 5206d0556c..4d21758408 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 @@ -962,11 +962,21 @@ 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(LocalBringIntoViewSpec provides object : BringIntoViewSpec { - override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f - }) { + CompositionLocalProvider( + LocalSelectionManager provides selectionManager, + 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, @@ -1893,7 +1903,7 @@ fun BoxScope.ChatItemsList( } false } - val swipeableModifier = SwipeToDismissModifier( + val swipeableModifier = if (appPlatform.isDesktop) Modifier else SwipeToDismissModifier( state = dismissState, directions = setOf(DismissDirection.EndToStart), swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, @@ -2194,8 +2204,10 @@ fun BoxScope.ChatItemsList( } } + val selectionModifier = SelectionHandler(LocalSelectionManager.current, listState, mergedItems, linkMode) + LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), + Modifier.align(Alignment.BottomCenter).then(selectionModifier), state = listState.value, contentPadding = PaddingValues( top = topPaddingToContent, @@ -2249,8 +2261,10 @@ fun BoxScope.ChatItemsList( itemSeparation = getItemSeparation(item, null) prevItemSeparationLargeGap = false } - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { - if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + CompositionLocalProvider(LocalItemContext provides ItemContext(selectionIndex = index)) { + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + } } if (last != null) { 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 new file mode 100644 index 0000000000..5a369ad444 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -0,0 +1,305 @@ +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.displayText +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 ItemContext( + val selectionIndex: Int = -1 +) + +val LocalItemContext = compositionLocalOf { ItemContext() } + +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 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 = displayText(ci, linkMode, sendCommandMsg = false) + 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") + } +} + +fun highlightedRange(range: SelectionRange?, 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 -> if (startOff == endOff) null else 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 + } +} + +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 idx = resolveIndexAtY(listState.value, localStart.y) + 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 idx = resolveIndexAtY(listState.value, change.position.y) + 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? { + return listState.layoutInfo.visibleItemsInfo.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 7aca0466f9..909e412e69 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,9 +1,11 @@ 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.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle @@ -12,6 +14,7 @@ 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) @@ -19,11 +22,45 @@ val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiF @Composable fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) { + val selectionManager = LocalSelectionManager.current + val selectionIndex = LocalItemContext.current.selectionIndex + 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 = if (selectionManager != null && selectionIndex >= 0) { + remember(selectionIndex) { derivedStateOf { highlightedRange(selectionManager.range, selectionIndex) != null } }.value + } else false + Column( Modifier.padding(vertical = 8.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - EmojiText(chatItem.content.text) + if (isSelected) { + Box(Modifier.background(SelectionHighlightColor)) { + EmojiText(chatItem.content.text) + } + } else { + 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 10cb86fceb..3178191995 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,8 +20,12 @@ 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 androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.ComposeState +import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers @@ -368,9 +372,53 @@ fun CIMarkdownText( showTimestamp: Boolean, prefix: AnnotatedString? = null ) { - 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 + val selectionManager = LocalSelectionManager.current + val selectionIndex = LocalItemContext.current.selectionIndex + val chatInfo = chat.chatInfo + val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + + val boundsState = remember { mutableStateOf(null) } + val layoutResultState = remember { mutableStateOf(null) } + + if (selectionManager != null && selectionIndex >= 0 && ci.meta.isLive != true) { + 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) + } + + 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) + } + } + } + } + + val highlightRange = if (selectionManager != null && selectionIndex >= 0) { + remember(selectionIndex) { derivedStateOf { highlightedRange(selectionManager.range, selectionIndex) } }.value + } else null + + Box( + Modifier + .padding(vertical = 7.dp, horizontal = 12.dp) + .then(if (selectionManager != null) Modifier.onGloballyPositioned { boundsState.value = it.boundsInWindow() } else Modifier) + ) { 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, @@ -379,7 +427,9 @@ fun CIMarkdownText( chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null }, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, + selectionRange = highlightRange, + onTextLayoutResult = if (selectionManager != null) { { layoutResultState.value = it } } else null ) } } 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 3984e5bc40..f300859f02 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 @@ -10,6 +10,7 @@ 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.* @@ -22,6 +23,7 @@ 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.* @@ -55,6 +57,35 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) +// Must be coordinated with MarkdownText — same text transformations for: +// Mention, HyperLink, SimplexLink, Command +fun displayText(ci: ChatItem, linkMode: SimplexLinkMode, sendCommandMsg: Boolean): String { + val formattedText = ci.formattedText + if (formattedText == null) return ci.text + return formattedText.joinToString("") { ft -> + when (ft.format) { + is Format.Mention -> { + val mention = ci.mentions?.get(ft.format.memberName) + if (mention?.memberRef != null) { + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName + else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" + mentionText(name) + } else if (mention != null) mentionText(ft.format.memberName) + else ft.text + } + is Format.HyperLink -> ft.format.showText ?: ft.text + is Format.SimplexLink -> { + val t = ft.format.showText + ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null + if (t != null) "$t ${ft.format.viaHosts}" else ft.text + } + is Format.Command -> if (sendCommandMsg) "/${ft.format.commandStr}" else ft.text + else -> ft.text + } + } +} + +// Text transformations in this function must match displayText above @Composable fun MarkdownText ( text: CharSequence, @@ -77,7 +108,9 @@ fun MarkdownText ( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, showTimestamp: Boolean = true, - prefix: AnnotatedString? = null + prefix: AnnotatedString? = null, + selectionRange: IntRange? = null, + onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -126,19 +159,27 @@ fun MarkdownText ( ) } if (formattedText == null) { + var selectableEnd = 0 val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (prefix != null) append(prefix) if (text is String) append(text) else if (text is AnnotatedString) append(text) + selectableEnd = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } + if (clampedRange != null) { + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + } else { + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + } } else { + var selectableEnd = 0 var hasLinks = false var hasSecrets = false var hasCommands = false @@ -247,6 +288,7 @@ fun MarkdownText ( is Format.Unknown -> append(ft.text) } } + selectableEnd = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } @@ -255,9 +297,10 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } + val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) { val icon = remember { mutableStateOf(PointerIcon.Default) } - ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -300,10 +343,15 @@ 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 { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + if (clampedRange != null) { + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + } else { + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + } } } } @@ -314,6 +362,7 @@ 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, @@ -354,9 +403,19 @@ 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(pressIndicator), + modifier = modifier.then(selectionHighlight).then(pressIndicator), style = style, softWrap = softWrap, overflow = overflow, @@ -368,6 +427,40 @@ 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) {