Merge branch 'master' into chat-relays

This commit is contained in:
spaced4ndy
2026-04-03 14:08:46 +04:00
15 changed files with 1168 additions and 53 deletions
@@ -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<ChatItem>,
newIds: Set<Long>,
splits: StateFlow<List<Long>>,
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
@@ -202,7 +202,8 @@ data class ActiveChatState (
// exclusive
val unreadAfter: MutableStateFlow<Int> = MutableStateFlow(0),
// exclusive
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0)
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0),
@Volatile var selectionActive: Boolean = false
) {
fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List<ChatItem>) {
toItemId ?: return
@@ -148,6 +148,7 @@ fun ChatView(
val showCommandsMenu = rememberSaveable { mutableStateOf(false) }
val contentFilter = rememberSaveable { mutableStateOf<ContentFilter?>(null) }
val availableContent = remember { mutableStateOf<List<ContentFilter>>(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) {
@@ -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<SelectionRange?>(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<LazyListState>? = 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<MergedItem>, 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<MergedItem>, 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<SelectionManager?> { 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<LazyListState>,
mergedItems: State<MergedItems>,
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<Rect?>(null) }
val layoutResultState = remember { mutableStateOf<TextLayoutResult?>(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)
}
}
@@ -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)
}
}
@@ -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
)
}
}
@@ -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<String>) -> 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<TextLayoutResult?>(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<TextLayoutResult?>): 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"
+1 -1
View File
@@ -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
+432
View File
@@ -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<MergedItem>, 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<Rect?>` — from `onGloballyPositioned` on the Box
- `layoutResultState: MutableState<TextLayoutResult?>` — 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
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -26,7 +26,7 @@
</a>
</li>
<hr>
<!-- <hr>
<li class="nav-link send-file {% if active_file %}active{% endif %}">
<a href="/file/">
@@ -34,7 +34,7 @@
</a>
</li>
<hr>
<hr> -->
<li class="nav-link">
<a href="javascript:void(0);">
+40 -3
View File
@@ -18,16 +18,53 @@ async function initDirectory() {
const topBtn = document.querySelector('#top-pagination .top');
const searchInput = document.getElementById('search');
allEntries = listing.entries
renderEntries('top', bySortPriority, topBtn)
searchInput.addEventListener('input', (e) => renderEntries('top', bySortPriority, topBtn, e.target.value.trim()));
applyHash();
searchInput.addEventListener('input', (e) => renderEntries('top', bySortPriority, topBtn, e.target.value.trim(), true));
liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn));
newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn));
topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn));
window.addEventListener('popstate', applyHash);
function applyHash() {
const hash = location.hash;
let mode, comparator, btn, search = '';
switch (hash) {
case '#active':
mode = 'live';
comparator = byActiveAtDesc;
btn = liveBtn;
case '#new':
mode = 'new';
comparator = byCreatedAtDesc;
btn = newBtn;
default:
mode = 'top';
comparator = bySortPriority;
btn = topBtn;
try {
if (hash.startsWith('#q=')) {
search = decodeURIComponent(hash.slice(3));
if (search) searchInput.value = search;
}
} catch(e) {}
}
currentSortMode = '';
currentSearch = '';
currentPage = 1;
renderEntries(mode, comparator, btn, search);
}
function renderEntries(mode, comparator, btn, search = '') {
if (currentSortMode === mode && search == currentSearch) return;
currentSortMode = mode;
if (location.hash) location.hash = '';
const hash = search ? '#q=' + encodeURIComponent(search)
: mode === 'live' ? '#active'
: mode === 'new' ? '#new'
: '';
const url = hash || (location.pathname + location.search);
history.replaceState(null, '', url);
liveBtn.classList.remove('active');
newBtn.classList.remove('active');
topBtn.classList.remove('active');
-1
View File
@@ -48,7 +48,6 @@ for lang in "${langs[@]}"; do
cp src/invitation.html src/$lang
cp src/fdroid.html src/$lang
cp src/why.html src/$lang
cp src/file.html src/$lang
echo "{\"lang\":\"$lang\"}" > src/$lang/$lang.json
echo "done $lang copying"
done