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 d0b9df6a18..addf5b9fd3 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 @@ -148,6 +148,7 @@ fun ChatView( val showCommandsMenu = rememberSaveable { mutableStateOf(false) } val contentFilter = rememberSaveable { mutableStateOf(null) } val availableContent = remember { mutableStateOf>(ContentFilter.initialList) } + val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null if (appPlatform.isAndroid) { DisposableEffect(Unit) { @@ -178,6 +179,7 @@ fun ChatView( contentFilter.value = null availableContent.value = ContentFilter.initialList selectedChatItems.value = null + selectionManager?.clearSelection() val cInfo = activeChat.value?.chatInfo if (chatsCtx.secondaryContextFilter == null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group || cInfo is ChatInfo.Local)) { updateAvailableContent(chatRh, activeChat, availableContent) @@ -245,6 +247,7 @@ fun ChatView( val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), + LocalSelectionManager provides selectionManager, ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -985,11 +988,20 @@ fun ChatLayout( val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chat != null) { + val selectionManager = LocalSelectionManager.current + 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( + 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, @@ -1018,6 +1030,13 @@ fun ChatLayout( CommandsMenuView(chatsCtx, chat, composeState, showCommandsMenu) } } + // Copy button inside TopStart-aligned wrapper — above messages, + // behind compose (ABPL paints compose after) and toolbars (outer Box paints after ABPL) + if (appPlatform.isDesktop) { + Box(Modifier.matchParentSize()) { + SelectionCopyButton() + } + } } } if (chatsCtx.contentTag == MsgContentTag.Report) { @@ -1195,25 +1214,6 @@ fun BoxScope.ChatInfoToolbar( chatInfo.contact.active && activeCall == null - // Content filter button: always in bar on desktop and for groups; on Android for direct chats it - // goes into the three-dots menu UNLESS calls are unavailable, in which case it appears in the bar - if (showContentFilterButton && (appPlatform.isDesktop || chatInfo is ChatInfo.Group || - (appPlatform.isAndroid && chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null))) { - val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready - barButtons.add { - IconButton( - { showContentFilterMenu.value = true }, - enabled = enabled - ) { - Icon( - painterResource(MR.images.ic_photo_library), - null, - tint = MaterialTheme.colors.primary - ) - } - } - } - // Chat-type specific buttons when (chatInfo) { is ChatInfo.Local -> { @@ -1312,6 +1312,26 @@ fun BoxScope.ChatInfoToolbar( else -> {} } + // Content filter button: always in bar on desktop and for groups; on Android for direct chats it + // goes into the three-dots menu UNLESS calls are unavailable, in which case it appears in the bar. + // Must be after chat-type buttons so call buttons appear before filter during active call. + if (showContentFilterButton && (appPlatform.isDesktop || chatInfo is ChatInfo.Group || + (appPlatform.isAndroid && chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null))) { + val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready + barButtons.add { + IconButton( + { showContentFilterMenu.value = true }, + enabled = enabled + ) { + Icon( + painterResource(MR.images.ic_photo_library), + null, + tint = MaterialTheme.colors.primary + ) + } + } + } + val enableNtfs = chatInfo.chatSettings?.enableNtfs if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) { val ntfMode = remember { mutableStateOf(enableNtfs) } @@ -1935,7 +1955,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() }, @@ -2320,8 +2340,11 @@ fun BoxScope.ChatItemsList( } } + val manager = LocalSelectionManager.current + val modifier = if (appPlatform.isDesktop && manager != null) SelectionHandler(manager, listState, mergedItems, linkMode) else Modifier + LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), + modifier.align(Alignment.BottomCenter), state = listState.value, contentPadding = PaddingValues( top = topPaddingToContent, @@ -2375,8 +2398,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..4a186f3c3d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -0,0 +1,518 @@ +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.draw.clip +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.geometry.Rect +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.onSizeChanged +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.item.itemSegmentDisplayText +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 anchorWindowY by mutableStateOf(0f) + private set + var anchorWindowX by mutableStateOf(0f) + private set + var focusWindowY by mutableStateOf(0f) + var focusWindowX by mutableStateOf(0f) + var viewportWidth by mutableStateOf(0f) + var viewportHeight by mutableStateOf(0f) + var viewportTop by mutableStateOf(0f) + var viewportBottom by mutableStateOf(0f) + var viewportPosition by mutableStateOf(Offset.Zero) + var focusCharRect by mutableStateOf(Rect.Zero) // X: absolute window, Y: relative to item + var listState: State? = null + var onCopySelection: (() -> Unit)? = null + private var autoScrollJob: Job? = null + + fun startSelection(startIndex: Int, anchorY: Float, anchorX: Float) { + range = SelectionRange(startIndex, -1, startIndex, -1) + selectionState = SelectionState.Selecting + anchorWindowY = anchorY + anchorWindowX = anchorX + } + + fun setAnchorOffset(offset: Int) { + val r = range ?: return + range = r.copy(startOffset = offset) + } + + fun updateFocusIndex(index: Int) { + val r = range ?: return + range = r.copy(endIndex = index) + } + + fun updateFocusOffset(offset: Int, charRect: Rect = Rect.Zero) { + val r = range ?: return + range = r.copy(endOffset = offset) + focusCharRect = charRect + } + + fun endSelection() { + autoScrollJob?.cancel() + autoScrollJob = null + selectionState = SelectionState.Selected + } + + // Snaps boundary offsets to include full transformed segments (mentions, links with showText). + fun snapSelection(items: List, linkMode: SimplexLinkMode) { + val r = range ?: return + val startCi = items.getOrNull(r.startIndex)?.newest()?.item + val endCi = items.getOrNull(r.endIndex)?.newest()?.item + // expandRight: snap in the direction that grows the selection + val startExpandRight = if (r.startIndex == r.endIndex) r.startOffset > r.endOffset else r.startIndex < r.endIndex + val endExpandRight = if (r.startIndex == r.endIndex) r.endOffset > r.startOffset else r.endIndex < r.startIndex + val snappedStart = if (startCi != null && r.startOffset >= 0) + snapOffset(startCi, r.startOffset, linkMode, expandRight = startExpandRight) + else r.startOffset + val snappedEnd = if (endCi != null && r.endOffset >= 0) + snapOffset(endCi, r.endOffset, linkMode, expandRight = endExpandRight) + else r.endOffset + if (snappedStart != r.startOffset || snappedEnd != r.endOffset) { + range = r.copy(startOffset = snappedStart, endOffset = snappedEnd) + } + } + + fun clearSelection() { + range = null + selectionState = SelectionState.Idle + } + + // Computes copy button position relative to the viewport (called during layout phase). + // Dragging down: button below focus char (top-left at char's bottom-right corner). + // Dragging up: button above focus char (bottom-right at char's top-left corner). + // focusCharRect X is absolute window coords, Y is relative to item. + fun copyButtonOffset(draggingDown: Boolean, gap: Float, buttonSize: IntSize): IntOffset { + val r = range ?: return IntOffset.Zero + val ls = listState?.value ?: return IntOffset.Zero + val itemInfo = ls.layoutInfo.visibleItemsInfo.find { it.index == r.endIndex } + ?: return IntOffset(-10000, -10000) // focus item scrolled off screen + // Item top in viewport coords (reversed layout: viewportEnd - offset - size) + val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat() + val cr = focusCharRect + val vp = viewportPosition + // Convert from window coords to viewport-relative + val charX = (if (draggingDown) cr.right else cr.left) - vp.x + val charY = itemWindowY + (if (draggingDown) cr.bottom else cr.top) - vp.y + // Anchor button corner at char corner with gap + val x = if (draggingDown) charX else (charX - buttonSize.width).coerceAtLeast(0f) + val y = if (draggingDown) charY + gap else charY - buttonSize.height - gap + val clampedX = x.coerceIn(0f, (viewportWidth - buttonSize.width).coerceAtLeast(0f)) + return IntOffset(clampedX.toInt(), y.toInt()) + } + + fun startDragSelection(localStart: Offset, windowStart: Offset, focusRequester: FocusRequester) { + val ls = listState?.value ?: return + val idx = resolveIndexAtY(ls, localStart.y) ?: return + startSelection(idx, windowStart.y, windowStart.x) + focusWindowY = windowStart.y + focusWindowX = windowStart.x + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + + fun updateDragFocus(windowPos: Offset, localY: Float) { + focusWindowY = windowPos.y + focusWindowX = windowPos.x + val ls = listState?.value ?: return + val idx = resolveIndexAtY(ls, localY) ?: return + updateFocusIndex(idx) + } + + fun updateAutoScroll(draggingDown: Boolean, pointerY: Float, scope: CoroutineScope) { + val edgeDistance = if (draggingDown) viewportBottom - pointerY else pointerY - viewportTop + if (edgeDistance !in 0f..AUTO_SCROLL_ZONE_PX) { + autoScrollJob?.cancel() + autoScrollJob = null + return + } + if (autoScrollJob?.isActive == true) return + val ls = listState ?: return + autoScrollJob = scope.launch { + while (isActive && selectionState == SelectionState.Selecting) { + val curEdge = if (draggingDown) viewportBottom - focusWindowY else 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 + ls.value.scrollBy(if (draggingDown) -speed else speed) + delay(16) + } + } + } + + fun getSelectedCopiedText(items: List, linkMode: SimplexLinkMode): String { + val r = range ?: return "" + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + return (lo..hi).mapNotNull { idx -> + val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + val sel = selectedRange(range, idx) ?: return@mapNotNull null + selectedItemCopiedText(ci, sel, linkMode) + }.reversed().joinToString("\n") + } +} + +// Returns the character range selected within a given item. +// Offsets are cursor positions (between characters), so the selected characters +// are those between min and max cursors: range is min..(max - 1). +// In reversed layout: higher index = higher on screen. +// startIndex/startOffset = anchor, endIndex/endOffset = focus. +fun selectedRange(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 + return when { + // Single-item selection: characters between the two cursor positions + index == r.startIndex && index == r.endIndex -> + if (r.startOffset < 0 || r.endOffset < 0 || r.startOffset == r.endOffset) null + else minOf(r.startOffset, r.endOffset) .. (maxOf(r.startOffset, r.endOffset) - 1) + // Anchor item in multi-item selection: from cursor to end, or from start to cursor + index == r.startIndex -> + if (r.startOffset < 0) null + else if (r.startIndex > r.endIndex) r.startOffset until Int.MAX_VALUE + else 0 until r.startOffset + // Focus item in multi-item selection: symmetric to anchor + index == r.endIndex -> + if (r.endOffset < 0) null + else if (r.endIndex < r.startIndex) 0 until r.endOffset + else r.endOffset until Int.MAX_VALUE + // Interior items: fully selected + else -> 0 until Int.MAX_VALUE + } +} + +// Extracts source text for the selected range within one item. +// Selection offsets are in display-text space. For transformed segments (mentions, links with showText), +// the full source is emitted if any part is selected. For untransformed segments, partial substring works. +private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: SimplexLinkMode): String { + val formattedText = ci.formattedText ?: return ci.text.substring( + sel.first.coerceAtMost(ci.text.length), + (sel.last + 1).coerceAtMost(ci.text.length) + ) + val sb = StringBuilder() + var displayOffset = 0 + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + val overlapStart = maxOf(displayOffset, sel.first) + val overlapEnd = minOf(displayEnd, sel.last + 1) + if (overlapStart < overlapEnd) { + if (ft.text.length == segDisplay.length) { + sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + } else { + sb.append(ft.text) + } + } + displayOffset = displayEnd + } + return sb.toString() +} + +// Snaps a boundary offset to include full transformed segments. +private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { + val formattedText = ci.formattedText ?: return offset + var displayOffset = 0 + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + return if (expandRight) displayEnd else displayOffset + } + displayOffset = displayEnd + } + return offset +} + +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 { + val touchSlop = LocalViewConfiguration.current.touchSlop + val clipboard = LocalClipboardManager.current + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + // 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 - manager.viewportPosition.y) + if (idx != null) manager.updateFocusIndex(idx) + } + } + } + + manager.listState = listState + manager.onCopySelection = { + clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, linkMode))) + manager.clearSelection() + showToast(generalGetString(MR.strings.copied)) + } + + return Modifier + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { event -> + if (manager.selectionState == SelectionState.Selected + && (event.isCtrlPressed || event.isMetaPressed) + && event.key == Key.C + && event.type == KeyEventType.KeyDown + ) { + manager.onCopySelection?.invoke() + true + } else false + } + .onGloballyPositioned { + val pos = it.positionInWindow() + val bounds = it.boundsInWindow() + manager.viewportTop = bounds.top + manager.viewportBottom = bounds.bottom + manager.viewportWidth = bounds.right - bounds.left + manager.viewportHeight = bounds.bottom - bounds.top + manager.viewportPosition = pos + } + .pointerInput(manager) { + awaitEachGesture { + val initialEvent = awaitPointerEvent(PointerEventPass.Initial).changes.first() + if (!initialEvent.pressed) return@awaitEachGesture + val localStart = initialEvent.position + val windowStart = localStart + manager.viewportPosition + if (manager.selectionState == SelectionState.Selected) initialEvent.consume() + var totalDrag = Offset.Zero + + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial).changes.first() + when (manager.selectionState) { + SelectionState.Idle -> { + if (!event.pressed) return@awaitEachGesture + totalDrag += event.positionChange() + if (totalDrag.getDistance() > touchSlop) { + manager.startDragSelection(localStart, windowStart, focusRequester) + event.consume() + } + } + SelectionState.Selected -> { + if (!event.pressed) { + manager.clearSelection() + return@awaitEachGesture + } + event.consume() + totalDrag += event.positionChange() + if (totalDrag.getDistance() > touchSlop) { + manager.startDragSelection(localStart, windowStart, focusRequester) + } + } + SelectionState.Selecting -> { + if (!event.pressed) { + manager.endSelection() + manager.snapSelection(mergedItems.value.items, linkMode) + return@awaitEachGesture + } + val windowPos = event.position + manager.viewportPosition + manager.updateDragFocus(windowPos, event.position.y) + event.consume() + manager.updateAutoScroll(windowPos.y > windowStart.y, windowPos.y, scope) + } + } + } + } + } +} + +private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { + val reversedY = listState.layoutInfo.viewportEndOffset - localY + val idx = listState.layoutInfo.visibleItemsInfo.find { item -> + reversedY >= item.offset && reversedY < item.offset + item.size + }?.index + return idx +} + +class ItemSelection( + val highlightRange: IntRange?, + val positionModifier: Modifier, + val onTextLayoutResult: ((TextLayoutResult) -> Unit)? +) + +// Sets up selection tracking for a text item: anchor/focus offset resolution, +// highlight range computation, and position/layout result capture. +@Composable +fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection { + val boundsState = remember { mutableStateOf(null) } + val layoutResultState = remember { mutableStateOf(null) } + + if (selectionManager != null && selectionIndex >= 0 && !isLive) { + 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.anchorWindowX - bounds.left, selectionManager.anchorWindowY - 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)) + val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1)) + val ls = selectionManager.listState?.value + val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex } + val charRect = if (ls != null && itemInfo != null) { + val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat() + Rect( + left = bounds.left + charBox.left, + top = bounds.top + charBox.top - itemWindowY, + right = bounds.left + charBox.right, + bottom = bounds.top + charBox.bottom - itemWindowY + ) + } else Rect.Zero + selectionManager.updateFocusOffset(offset, charRect) + } + } + } + } + + val highlightRange = if (selectionManager != null && selectionIndex >= 0) { + remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value + } else null + + val positionModifier = if (selectionManager != null) { + Modifier.onGloballyPositioned { + val pos = it.positionInWindow() + boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + } + } else Modifier + + val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) { + { layoutResultState.value = it } + } else null + + return ItemSelection(highlightRange, positionModifier, onTextLayoutResult) +} + +// Sets up full-item selection for emoji items (no character-level tracking). +@Composable +fun setupEmojiSelection(selectionManager: SelectionManager?, selectionIndex: Int, textLength: Int): Boolean { + if (selectionManager == null || selectionIndex < 0) return false + + 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(textLength) } + } + } + + return remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) != null } }.value +} + +@Composable +fun SelectionCopyButton() { + val manager = LocalSelectionManager.current ?: return + val range = manager.range ?: return + if (manager.selectionState != SelectionState.Selected || manager.focusCharRect == Rect.Zero) return + val draggingDown = range.startIndex > range.endIndex || (range.startIndex == range.endIndex && range.startOffset < range.endOffset) + val gap = with(LocalDensity.current) { 4.dp.toPx() } + var buttonSize by remember { mutableStateOf(IntSize.Zero) } + Row( + Modifier + .offset { manager.copyButtonOffset(draggingDown, gap, buttonSize) } + .onSizeChanged { buttonSize = it } + .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(20.dp)) + .clickable { manager.onCopySelection?.invoke() } + .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..3bcd02411f 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,20 @@ val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiF @Composable fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) { + val emojiText = chatItem.content.text.trim() + val isSelected = setupEmojiSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, emojiText.length) + 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..8aab0bbbb6 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 @@ -21,7 +21,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* 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 +368,11 @@ 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 chatInfo = chat.chatInfo + val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true) + + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) { 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 +381,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 = selection.highlightRange, + onTextLayoutResult = selection.onTextLayoutResult ) } } 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..9e8583a79b 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)) +// Display text for a single formatted segment — must be coordinated with MarkdownText. +fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = + 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 -> ft.text + else -> ft.text + } + +// Full display text for a chat item — joins segment display texts. +fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { + val formattedText = ci.formattedText ?: return ci.text + return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } +} + +// Text transformations in MarkdownText must match itemSegmentDisplayText 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 .. minOf(it.last, selectableEnd) } + if (onTextLayoutResult != 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 .. 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 (onTextLayoutResult != 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, @@ -356,7 +405,7 @@ fun ClickableText( BasicText( text = text, - modifier = modifier.then(pressIndicator), + modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator), style = style, softWrap = softWrap, overflow = overflow, @@ -368,6 +417,42 @@ 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) } + + BasicText( + text = text, + modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)), + style = style, + maxLines = maxLines, + overflow = overflow, + onTextLayout = { + layoutResult.value = it + onTextLayoutResult?.invoke(it) + } + ) +} + +private fun selectionHighlight(selectionRange: IntRange?, textLength: Int, layoutResult: State): Modifier = + if (selectionRange != null) { + Modifier.drawBehind { + layoutResult.value?.let { result -> + if (selectionRange.first <= selectionRange.last && selectionRange.last + 1 <= textLength) { + drawPath(result.getPathForRange(selectionRange.first, selectionRange.last + 1), SelectionHighlightColor) + } + } + } + } else Modifier + fun openBrowserAlert(uri: String, uriHandler: UriHandler) { val (res, err) = sanitizeUri(uri) if (res == null) { @@ -446,4 +531,4 @@ private fun isRtl(s: CharSequence): Boolean { return false } -private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" +fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" diff --git a/cabal.project b/cabal.project index 33dde2dd0b..588665a285 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9bc0c70fa0604a11f7f19d4b4415b0bb7414582c + tag: b82cf7d001c2719dbe8e48e1c84dbbd9d485688d source-repository-package type: git diff --git a/plans/2026-03-29-desktop-text-selection.md b/plans/2026-03-29-desktop-text-selection.md new file mode 100644 index 0000000000..888000ab4c --- /dev/null +++ b/plans/2026-03-29-desktop-text-selection.md @@ -0,0 +1,432 @@ +# Desktop Text Selection Plan + +## Goal +Cross-message text selection on desktop (Compose Multiplatform): +1. Click+drag to select message text, with auto-scroll +2. Only message text is selectable (no timestamps, names, quotes, dates — like Telegram web) +3. Ctrl+C and copy button +4. Selection persists across scroll + +## Architecture + +### Selection State + +Selection is two endpoints in the item list: + +```kotlin +data class SelectionRange( + val startIndex: Int, // anchor — where drag began, immutable during drag + val startOffset: Int, // character offset within anchor item + val endIndex: Int, // focus — where pointer is now + val endOffset: Int // character offset within focus item +) +``` + +```kotlin +enum class SelectionState { Idle, Selecting, Selected } +``` + +SelectionManager holds: +```kotlin +var selectionState: SelectionState // mutableStateOf +var range: SelectionRange? // mutableStateOf, null in Idle +var focusWindowY by mutableStateOf(0f) // pointer Y in window coords +var focusWindowX by mutableStateOf(0f) // pointer X in window coords +``` + +No captured map. No eager text extraction. +Indices are stable across scroll. Text extracted at copy time from live data. + +### State Machine + +``` + drag threshold + Idle ─────────────────→ Selecting + ↑ │ + │ click │ pointer up + │ ▼ + ←──────────────────── Selected +``` + +### Pointer Handler (on LazyColumn Modifier) + +`SelectionHandler` composable (BoxScope extension) returns a Modifier for +LazyColumnWithScrollBar. Contains `pointerInput`, `onGloballyPositioned`, +`focusRequester`, `focusable`, `onKeyEvent`. + +On every pointer move during Selecting: +1. Updates `focusWindowY/X` +2. Uses `listState.layoutInfo.visibleItemsInfo` to find item at pointer Y → updates `range.endIndex` + +Index resolution uses LazyListState directly — no map, no registration. + +### Pointer Handler Behavior Per State + +Non-press events (hover, scroll) skipped: `return@awaitEachGesture`. +State captured at gesture start (`wasSelected`). + +**Idle**: Down not consumed. Links/menus work. Drag threshold → Selecting. +**Selecting**: Pointer move → update focusWindowY/X, resolve endIndex via listState. + Pointer up → Selected. +**Selected**: Down consumed (prevents link activation). Click → Idle. Drag → new Selecting. + +### Anchor Char Offset Resolution + +The anchor item knows it's the anchor: `range.startIndex == myIndex`. +Resolves char offset ONCE at selection start via LaunchedEffect: + +```kotlin +val isAnchor = remember(myIndex) { + derivedStateOf { manager.range?.startIndex == myIndex && manager.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(manager.focusWindowX - bounds.left, manager.focusWindowY - bounds.top) + ) + manager.setAnchorOffset(offset) +} +``` + +Fires once. No ongoing effect. + +### Focus Char Offset Resolution + +The focus item knows it's the focus: `range.endIndex == myIndex`. +Resolves char offset on every pointer move via snapshotFlow: + +```kotlin +val isFocus = remember(myIndex) { + derivedStateOf { manager.range?.endIndex == myIndex && manager.selectionState == SelectionState.Selecting } +} +if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { manager.focusWindowY to manager.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)) + manager.updateFocusOffset(offset) + } + } +} +``` + +- Starts when item becomes focus, cancels when focus moves to different item +- snapshotFlow fires on pointer move, but only in ONE item +- Uses item's own local TextLayoutResult — no shared map + +### Highlight Rendering (Per Item) + +Each item computes highlight via derivedStateOf: + +```kotlin +val highlightRange = remember(myIndex) { + derivedStateOf { highlightedRange(manager.range, myIndex) } +} +``` + +`highlightedRange` is a standalone function: +```kotlin +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 -> minOf(startOff, endOff) until maxOf(startOff, endOff) + index == lo -> startOff until Int.MAX_VALUE // clamped by MarkdownText + index == hi -> 0 until endOff + else -> 0 until Int.MAX_VALUE // clamped by MarkdownText + } +} +``` + +derivedStateOf only triggers recomposition when the RESULT changes for this item. +Middle items don't recompose as range extends. Only boundary items recompose. + +### Highlight Drawing + +`getPathForRange(range.first, range.last + 1)` in `drawBehind` on BasicText. +`range.last + 1` because IntRange.last is inclusive, getPathForRange end is exclusive. + +Gated on `selectionRange != null`: +- When null (Android, or desktop without selection): original `Text()` used, no drawBehind. +- When non-null: `SelectableText` (BasicText + drawBehind + onTextLayout) or + `ClickableText` with added drawBehind. + +### Reserve Space Exclusion + +MarkdownText's `buildAnnotatedString` appends invisible reserve text after message +content. A local `var selectableEnd` is set to `this.length` inside `buildAnnotatedString` +right before reserve is appended. Used to clamp `selectionRange` before passing +downstream to rendering: + +```kotlin +var selectableEnd = 0 +val annotatedText = buildAnnotatedString { + // ... content ... + selectableEnd = this.length + // ... typing indicator, reserve ... +} +val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } +// pass clampedRange to ClickableText/SelectableText +``` + +`selectableEnd` is local to MarkdownText. Not passed upstream. +`highlightedRange` uses `Int.MAX_VALUE` for open-ended ranges; +MarkdownText resolves them to the actual content boundary. + +### Copy + +#### `displayText` function + +Non-composable function placed right next to MarkdownText in TextItemView.kt. +Computes the displayed text from `formattedText`, handling only the few Format +types that change the displayed string. All other formats use `ft.text` unchanged. +Used only at copy time. + +```kotlin +// 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 -> { /* resolve display name from ci.mentions */ } + is Format.HyperLink -> ft.format.showText ?: ft.text + is Format.SimplexLink -> { /* showText or description + viaHosts */ } + is Format.Command -> if (sendCommandMsg) "/${ft.format.commandStr}" else ft.text + else -> ft.text + } + } +} +``` + +MarkdownText gets a corresponding comment noting these transformations must match. + +#### Copy text extraction + +On SelectionManager: +```kotlin +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") +} +``` + +### Auto-Scroll + +Direction-aware: only the edge you're dragging toward. +After `scrollBy()`, re-resolve index from `listState.layoutInfo.visibleItemsInfo` +with same pointer Y. Different item may be under pointer → endIndex updates. +Indices don't shift on scroll. Focus item's snapshotFlow handles new charOffset. + +### Mouse Wheel During Drag + +Scroll event passes through to LazyColumn (not consumed by handler). +`snapshotFlow` on scroll offset fires → re-resolve index from listState → update endIndex. + +### Ctrl+C / Cmd+C + +`onKeyEvent` on LazyColumn modifier (inside SelectionHandler's returned Modifier). +Focus requested on selection start. When user taps compose box, focus moves there — +Ctrl+C goes to compose box handler. Copy button works regardless of focus. +Checks `isCtrlPressed || isMetaPressed`. + +### Copy Button + +Emitted by SelectionHandler in BoxScope. Visible in Selected state. +Copies without clearing. Click in chat clears selection. + +### Eviction Prevention + +`ChatItemsLoader.kt`: `allowedTrimming = !selectionActive` during selection. + +### Platform Gate + +All selection code gated on `appPlatform.isDesktop`. + +### Swipe-to-Reply + +Disabled on desktop: `if (appPlatform.isDesktop) Modifier else swipeableModifier`. + +### RTL Text + +`getOffsetForPosition` and `getPathForRange` are bidi-aware. No direction assumptions. + +--- + +## Effects Summary + +### Idle State +Zero effects. Items don't check anything. `range` is null. + +### Selecting State + +| What | Scope | Fires when | +|------|-------|-----------| +| Pointer event handling | LazyColumn pointerInput (total: 1) | Every pointer event | +| Index resolution | Pointer handler via listState (total: 1) | Every pointer move + scroll | +| Anchor char offset | Anchor item LaunchedEffect (1 item) | Once at selection start | +| Focus char offset | Focus item snapshotFlow (1 item) | Every pointer move | +| Highlight derivedStateOf | Per item (passive) | Only when result changes (~2 items) | +| Auto-scroll | Coroutine in pointer handler (total: 0 or 1) | Near edge during drag | +| Scroll re-evaluation | snapshotFlow on scroll offset (total: 1) | On scroll during drag | + +### Selected State +Zero effects. Frozen range. Items render highlight from derivedStateOf (no recomposition +unless range changes, which it doesn't in Selected state). + +--- + +## Changes From Master + +### NEW: TextSelection.kt + +New file: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt` + +Contains: +- `SelectionRange(startIndex, startOffset, endIndex, endOffset)` data class +- `SelectionState` enum (Idle, Selecting, Selected) +- `SelectionManager` — holds `selectionState`, `range`, `focusWindowY/X` (mutableStateOf), + methods: `startSelection`, `setAnchorOffset`, `updateFocusIndex`, `updateFocusOffset`, + `endSelection`, `clearSelection`, `getSelectedText(items, linkMode)` +- `highlightedRange(range, index)` standalone function +- `LocalSelectionManager` CompositionLocal +- `SelectionHandler` composable (BoxScope extension, returns Modifier for LazyColumn): + pointer input with state machine, auto-scroll, focus management, Ctrl+C/Cmd+C, copy button +- `SelectionCopyButton` composable +- `resolveIndexAtY` helper for pointer → item index via listState + +### TextItemView.kt + +**Add `displayText` function** right next to MarkdownText, with comment that it +must be coordinated with MarkdownText's text transformations. Takes `ChatItem`, +`linkMode`, `sendCommandMsg`. Used only by `getSelectedText` at copy time. + +**Add comment to MarkdownText** noting `displayText` must match its text transformations. + +**Add 2 parameters to MarkdownText**: +- `selectionRange: IntRange? = null` +- `onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null` + +**Inside MarkdownText** — local `var selectableEnd` set in both `buildAnnotatedString` +blocks (1 line each, right before typing indicator / reserve). Clamp selectionRange: +```kotlin +val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } +``` + +**Rendering** — gated on `clampedRange != null`: +- `Text()` call sites (2): `if (clampedRange != null) SelectableText(...) else Text(...)` + Original `Text(...)` call unchanged. +- `ClickableText` call: add `selectionRange = clampedRange`, + add `onTextLayout = { onTextLayoutResult?.invoke(it) }` + +**Add `selectionRange` parameter to `ClickableText`**, add `drawBehind` highlight +with `getPathForRange(range.first, range.last + 1)` before BasicText. + +**Add `SelectableText` private composable** — BasicText + drawBehind highlight + +onTextLayout. Used only when `selectionRange != null`. On Android, never reached. + +**MarkdownText is NOT restructured.** No code moved, no branches regrouped. + +### FramedItemView.kt — CIMarkdownText + +**Add `selectionIndex: Int = -1` parameter.** + +**Add** (gated on `selectionManager != null && selectionIndex >= 0 && !ci.meta.isLive`): +- `boundsState: MutableState` — from `onGloballyPositioned` on the Box +- `layoutResultState: MutableState` — from `onTextLayoutResult` +- `isAnchor` derivedStateOf + LaunchedEffect (resolves anchor offset once) +- `isFocus` derivedStateOf + LaunchedEffect with snapshotFlow (resolves focus offset) +- `highlightRange` via `derivedStateOf { highlightedRange(manager.range, selectionIndex) }` + +**MarkdownText call**: add `selectionRange = highlightRange`, +`onTextLayoutResult = { layoutResultState.value = it }` + +### EmojiItemView.kt + +**Add `selectionIndex: Int = -1` parameter.** + +**Add** (gated on `selectionManager != null && selectionIndex >= 0`): +- `isAnchor`/`isFocus` LaunchedEffects (full-selection only: offset 0 / emojiText.length) +- `isSelected` via `derivedStateOf { highlightedRange(manager.range, selectionIndex) != null }` +- Highlight via `Modifier.background(SelectionHighlightColor)` when selected + +### ChatView.kt + +- Create `SelectionManager`, provide via `LocalSelectionManager` +- `SelectionHandler` returns Modifier applied to LazyColumnWithScrollBar +- Pass `selectionIndex` from `itemsIndexed` through the call chain: + `ChatViewListItem` → `ChatItemViewShortHand` → `ChatItemView` (item/) → + `FramedItemView` → `CIMarkdownText`. Each gets `selectionIndex: Int = -1` param. +- Same for EmojiItemView path +- Gate SwipeToDismiss on desktop: `if (appPlatform.isDesktop) Modifier else swipeableModifier` +- Sync `selectionState != Idle` to `chatState.selectionActive` via LaunchedEffect + +### ChatItemsLoader.kt + +- `removeDuplicatesAndModifySplitsOnBeforePagination`: add `selectionActive: Boolean = false` param +- `allowedTrimming = !selectionActive` +- Call site passes `chatState.selectionActive` + +### ChatItemsMerger.kt + +- `ActiveChatState`: add `@Volatile var selectionActive: Boolean = false` + +### ChatModel.kt — no change + +### MarkdownHelpView.kt — no change + +--- + +## Testing + +1. Single message partial character selection +2. Multi-message selection with highlights +3. Direction reversal past anchor +4. Selection shrinks on reverse (items unhighlight) +5. Selection persists after drag end and across scroll +6. Auto-scroll extends selection correctly +7. Auto-scroll loads items from DB +8. Mouse wheel during drag extends selection +9. Items scrolling out and back in retain highlight +10. Click on links works (Idle state) +11. Click in chat clears selection (Selected state) +12. Right-click behavior +13. Ctrl+C / Cmd+C copies selected text +14. Copy button works +15. Highlight stops before invisible reserve space +16. Copy produces clean text +17. RTL text +18. Emoji-only messages +19. Live messages excluded +20. Edited messages during selection diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index fd48317286..30a66cb03c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9bc0c70fa0604a11f7f19d4b4415b0bb7414582c" = "13i6j1nw5w0a2bpjkw6adglf6x81nk5anf8pnjqijzfpggjzdj7w"; + "https://github.com/simplex-chat/simplexmq.git"."b82cf7d001c2719dbe8e48e1c84dbbd9d485688d" = "15qsgcq27h9ajhrdr91bll8vfgjd8qvsfcbf7wbd4ngl19avr944"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/website/.eleventy.js b/website/.eleventy.js index 1a45d33418..1a98609f1a 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -53,7 +53,7 @@ const globalConfig = { } const translationsDirectoryPath = './langs' -const supportedRoutes = ["blog", "contact", "invitation", "messaging", "docs", "fdroid", "why", "file", ""] +const supportedRoutes = ["blog", "contact", "invitation", "messaging", "docs", "fdroid", "why", ""] let supportedLangs = [] fs.readdir(translationsDirectoryPath, (err, files) => { if (err) { diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 79032db8ed..9e1913879f 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -26,7 +26,7 @@ -
+