mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-24 19:35:33 +00:00
Merge branch 'master' into chat-relays
This commit is contained in:
+5
-3
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+51
-26
@@ -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) {
|
||||
|
||||
+518
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-5
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+92
-7
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user