mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 15:03:16 +00:00
new implementation
This commit is contained in:
1 parent
2d14e14efe
commit
9ab1fb7e8f
7 files changed
+526
-24
No files matched your search
+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
|
||||
|
||||
+21
-7
@@ -962,11 +962,21 @@ fun ChatLayout(
|
||||
val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null }
|
||||
AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) {
|
||||
if (chat != null) {
|
||||
val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null
|
||||
if (selectionManager != null) {
|
||||
LaunchedEffect(selectionManager) {
|
||||
snapshotFlow { selectionManager.selectionState != SelectionState.Idle }
|
||||
.collect { chatsCtx.chatState.selectionActive = it }
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
|
||||
// disables scrolling to top of chat item on click inside the bubble
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec {
|
||||
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f
|
||||
}) {
|
||||
CompositionLocalProvider(
|
||||
LocalSelectionManager provides selectionManager,
|
||||
LocalBringIntoViewSpec provides object : BringIntoViewSpec {
|
||||
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f
|
||||
}
|
||||
) {
|
||||
ChatItemsList(
|
||||
chatsCtx, remoteHostId, chat, unreadCount, composeState, composeViewHeight, searchValue,
|
||||
useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports,
|
||||
@@ -1893,7 +1903,7 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
false
|
||||
}
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
val swipeableModifier = if (appPlatform.isDesktop) Modifier else SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = setOf(DismissDirection.EndToStart),
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
@@ -2194,8 +2204,10 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
}
|
||||
|
||||
val selectionModifier = SelectionHandler(LocalSelectionManager.current, listState, mergedItems, linkMode)
|
||||
|
||||
LazyColumnWithScrollBar(
|
||||
Modifier.align(Alignment.BottomCenter),
|
||||
Modifier.align(Alignment.BottomCenter).then(selectionModifier),
|
||||
state = listState.value,
|
||||
contentPadding = PaddingValues(
|
||||
top = topPaddingToContent,
|
||||
@@ -2249,8 +2261,10 @@ fun BoxScope.ChatItemsList(
|
||||
itemSeparation = getItemSeparation(item, null)
|
||||
prevItemSeparationLargeGap = false
|
||||
}
|
||||
ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) {
|
||||
if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems)
|
||||
CompositionLocalProvider(LocalItemContext provides ItemContext(selectionIndex = index)) {
|
||||
ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) {
|
||||
if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems)
|
||||
}
|
||||
}
|
||||
|
||||
if (last != null) {
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.views.chat.item.displayText
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
val SelectionHighlightColor = Color(0x4D0066FF)
|
||||
|
||||
data class ItemContext(
|
||||
val selectionIndex: Int = -1
|
||||
)
|
||||
|
||||
val LocalItemContext = compositionLocalOf { ItemContext() }
|
||||
|
||||
data class SelectionRange(
|
||||
val startIndex: Int,
|
||||
val startOffset: Int,
|
||||
val endIndex: Int,
|
||||
val endOffset: Int
|
||||
)
|
||||
|
||||
enum class SelectionState { Idle, Selecting, Selected }
|
||||
|
||||
class SelectionManager {
|
||||
var selectionState by mutableStateOf(SelectionState.Idle)
|
||||
private set
|
||||
|
||||
var range by mutableStateOf<SelectionRange?>(null)
|
||||
private set
|
||||
|
||||
var focusWindowY by mutableStateOf(0f)
|
||||
var focusWindowX by mutableStateOf(0f)
|
||||
|
||||
fun startSelection(startIndex: Int) {
|
||||
range = SelectionRange(startIndex, 0, startIndex, 0)
|
||||
selectionState = SelectionState.Selecting
|
||||
}
|
||||
|
||||
fun setAnchorOffset(offset: Int) {
|
||||
val r = range ?: return
|
||||
range = r.copy(startOffset = offset, endOffset = offset)
|
||||
}
|
||||
|
||||
fun updateFocusIndex(index: Int) {
|
||||
val r = range ?: return
|
||||
range = r.copy(endIndex = index)
|
||||
}
|
||||
|
||||
fun updateFocusOffset(offset: Int) {
|
||||
val r = range ?: return
|
||||
range = r.copy(endOffset = offset)
|
||||
}
|
||||
|
||||
fun endSelection() {
|
||||
selectionState = SelectionState.Selected
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
range = null
|
||||
selectionState = SelectionState.Idle
|
||||
}
|
||||
|
||||
fun getSelectedText(items: List<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")
|
||||
}
|
||||
}
|
||||
|
||||
fun highlightedRange(range: SelectionRange?, index: Int): IntRange? {
|
||||
val r = range ?: return null
|
||||
val lo = minOf(r.startIndex, r.endIndex)
|
||||
val hi = maxOf(r.startIndex, r.endIndex)
|
||||
if (index < lo || index > hi) return null
|
||||
val forward = r.startIndex <= r.endIndex
|
||||
val startOff = if (forward) r.startOffset else r.endOffset
|
||||
val endOff = if (forward) r.endOffset else r.startOffset
|
||||
return when {
|
||||
index == lo && index == hi -> if (startOff == endOff) null else minOf(startOff, endOff) until maxOf(startOff, endOff)
|
||||
index == lo -> startOff until Int.MAX_VALUE
|
||||
index == hi -> 0 until endOff
|
||||
else -> 0 until Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
val LocalSelectionManager = staticCompositionLocalOf<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 {
|
||||
if (manager == null || !appPlatform.isDesktop) return Modifier
|
||||
|
||||
val touchSlop = LocalViewConfiguration.current.touchSlop
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var positionInWindow by remember { mutableStateOf(Offset.Zero) }
|
||||
var viewportTop by remember { mutableStateOf(0f) }
|
||||
var viewportBottom by remember { mutableStateOf(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var autoScrollJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
// Re-evaluate focus index on scroll during active drag
|
||||
LaunchedEffect(manager) {
|
||||
snapshotFlow { listState.value.firstVisibleItemScrollOffset }
|
||||
.collect {
|
||||
if (manager.selectionState == SelectionState.Selecting) {
|
||||
val idx = resolveIndexAtY(listState.value, manager.focusWindowY - positionInWindow.y)
|
||||
if (idx != null) manager.updateFocusIndex(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy button
|
||||
if (manager.selectionState == SelectionState.Selected) {
|
||||
SelectionCopyButton(
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(manager.getSelectedText(mergedItems.value.items, linkMode)))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent { event ->
|
||||
if (manager.selectionState == SelectionState.Selected
|
||||
&& (event.isCtrlPressed || event.isMetaPressed)
|
||||
&& event.key == Key.C
|
||||
&& event.type == KeyEventType.KeyDown
|
||||
) {
|
||||
clipboard.setText(AnnotatedString(manager.getSelectedText(mergedItems.value.items, linkMode)))
|
||||
true
|
||||
} else false
|
||||
}
|
||||
.onGloballyPositioned {
|
||||
positionInWindow = it.positionInWindow()
|
||||
val bounds = it.boundsInWindow()
|
||||
viewportTop = bounds.top
|
||||
viewportBottom = bounds.bottom
|
||||
}
|
||||
.pointerInput(manager) {
|
||||
awaitEachGesture {
|
||||
val down = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val firstChange = down.changes.first()
|
||||
if (!firstChange.pressed) return@awaitEachGesture
|
||||
|
||||
val wasSelected = manager.selectionState == SelectionState.Selected
|
||||
if (wasSelected) firstChange.consume()
|
||||
|
||||
val localStart = firstChange.position
|
||||
val windowStart = localStart + positionInWindow
|
||||
var totalDrag = Offset.Zero
|
||||
var isDragging = false
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.first()
|
||||
|
||||
if (!change.pressed) {
|
||||
autoScrollJob?.cancel()
|
||||
autoScrollJob = null
|
||||
if (isDragging) {
|
||||
manager.endSelection()
|
||||
} else if (wasSelected) {
|
||||
manager.clearSelection()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
totalDrag += change.positionChange()
|
||||
|
||||
if (!isDragging && totalDrag.getDistance() > touchSlop) {
|
||||
isDragging = true
|
||||
val idx = resolveIndexAtY(listState.value, localStart.y)
|
||||
if (idx != null) {
|
||||
manager.startSelection(idx)
|
||||
manager.focusWindowY = windowStart.y
|
||||
manager.focusWindowX = windowStart.x
|
||||
}
|
||||
try { focusRequester.requestFocus() } catch (_: Exception) {}
|
||||
change.consume()
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
val windowPos = change.position + positionInWindow
|
||||
manager.focusWindowY = windowPos.y
|
||||
manager.focusWindowX = windowPos.x
|
||||
|
||||
val idx = resolveIndexAtY(listState.value, change.position.y)
|
||||
if (idx != null) manager.updateFocusIndex(idx)
|
||||
|
||||
change.consume()
|
||||
|
||||
// Auto-scroll: direction-aware
|
||||
val draggingDown = windowPos.y > windowStart.y
|
||||
val edgeDistance = if (draggingDown) {
|
||||
viewportBottom - windowPos.y
|
||||
} else {
|
||||
windowPos.y - viewportTop
|
||||
}
|
||||
val shouldAutoScroll = edgeDistance in 0f..AUTO_SCROLL_ZONE_PX
|
||||
|
||||
if (shouldAutoScroll && autoScrollJob?.isActive != true) {
|
||||
autoScrollJob = scope.launch {
|
||||
while (isActive && manager.selectionState == SelectionState.Selecting) {
|
||||
val curEdge = if (draggingDown) {
|
||||
viewportBottom - manager.focusWindowY
|
||||
} else {
|
||||
manager.focusWindowY - viewportTop
|
||||
}
|
||||
if (curEdge >= AUTO_SCROLL_ZONE_PX) break
|
||||
|
||||
val fraction = 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f)
|
||||
val speed = MIN_SCROLL_SPEED + (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED) * fraction
|
||||
listState.value.scrollBy(if (draggingDown) -speed else speed)
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
} else if (!shouldAutoScroll) {
|
||||
autoScrollJob?.cancel()
|
||||
autoScrollJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? {
|
||||
return listState.layoutInfo.visibleItemsInfo.find { item ->
|
||||
localY >= item.offset && localY < item.offset + item.size
|
||||
}?.index
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.SelectionCopyButton(onCopy: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 8.dp)
|
||||
.background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp))
|
||||
.clickable { onCopy() }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_content_copy), null, Modifier.size(16.dp), tint = MaterialTheme.colors.primary)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(generalGetString(MR.strings.copy_verb), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
+39
-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,45 @@ val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiF
|
||||
|
||||
@Composable
|
||||
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) {
|
||||
val selectionManager = LocalSelectionManager.current
|
||||
val selectionIndex = LocalItemContext.current.selectionIndex
|
||||
val emojiText = chatItem.content.text.trim()
|
||||
|
||||
if (selectionManager != null && selectionIndex >= 0) {
|
||||
val isAnchor = remember(selectionIndex) {
|
||||
derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting }
|
||||
}
|
||||
LaunchedEffect(isAnchor.value) {
|
||||
if (!isAnchor.value) return@LaunchedEffect
|
||||
selectionManager.setAnchorOffset(0)
|
||||
}
|
||||
|
||||
val isFocus = remember(selectionIndex) {
|
||||
derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting }
|
||||
}
|
||||
if (isFocus.value) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { selectionManager.focusWindowY }
|
||||
.collect { selectionManager.updateFocusOffset(emojiText.length) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isSelected = if (selectionManager != null && selectionIndex >= 0) {
|
||||
remember(selectionIndex) { derivedStateOf { highlightedRange(selectionManager.range, selectionIndex) != null } }.value
|
||||
} else false
|
||||
|
||||
Column(
|
||||
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EmojiText(chatItem.content.text)
|
||||
if (isSelected) {
|
||||
Box(Modifier.background(SelectionHighlightColor)) {
|
||||
EmojiText(chatItem.content.text)
|
||||
}
|
||||
} else {
|
||||
EmojiText(chatItem.content.text)
|
||||
}
|
||||
CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
+55
-5
@@ -20,8 +20,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -368,9 +372,53 @@ fun CIMarkdownText(
|
||||
showTimestamp: Boolean,
|
||||
prefix: AnnotatedString? = null
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
|
||||
val chatInfo = chat.chatInfo
|
||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||
val selectionManager = LocalSelectionManager.current
|
||||
val selectionIndex = LocalItemContext.current.selectionIndex
|
||||
val chatInfo = chat.chatInfo
|
||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||
|
||||
val boundsState = remember { mutableStateOf<Rect?>(null) }
|
||||
val layoutResultState = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
|
||||
if (selectionManager != null && selectionIndex >= 0 && ci.meta.isLive != true) {
|
||||
val isAnchor = remember(selectionIndex) {
|
||||
derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting }
|
||||
}
|
||||
LaunchedEffect(isAnchor.value) {
|
||||
if (!isAnchor.value) return@LaunchedEffect
|
||||
val bounds = boundsState.value ?: return@LaunchedEffect
|
||||
val layout = layoutResultState.value ?: return@LaunchedEffect
|
||||
val offset = layout.getOffsetForPosition(
|
||||
Offset(selectionManager.focusWindowX - bounds.left, selectionManager.focusWindowY - bounds.top)
|
||||
)
|
||||
selectionManager.setAnchorOffset(offset)
|
||||
}
|
||||
|
||||
val isFocus = remember(selectionIndex) {
|
||||
derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting }
|
||||
}
|
||||
if (isFocus.value) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX }
|
||||
.collect { (py, px) ->
|
||||
val bounds = boundsState.value ?: return@collect
|
||||
val layout = layoutResultState.value ?: return@collect
|
||||
val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top))
|
||||
selectionManager.updateFocusOffset(offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val highlightRange = if (selectionManager != null && selectionIndex >= 0) {
|
||||
remember(selectionIndex) { derivedStateOf { highlightedRange(selectionManager.range, selectionIndex) } }.value
|
||||
} else null
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.padding(vertical = 7.dp, horizontal = 12.dp)
|
||||
.then(if (selectionManager != null) Modifier.onGloballyPositioned { boundsState.value = it.boundsInWindow() } else Modifier)
|
||||
) {
|
||||
MarkdownText(
|
||||
text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
|
||||
sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null,
|
||||
@@ -379,7 +427,9 @@ fun CIMarkdownText(
|
||||
chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId
|
||||
else -> null
|
||||
},
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix,
|
||||
selectionRange = highlightRange,
|
||||
onTextLayoutResult = if (selectionManager != null) { { layoutResultState.value = it } } else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+99
-6
@@ -10,6 +10,7 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.platform.*
|
||||
@@ -22,6 +23,7 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.views.chat.SelectionHighlightColor
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.*
|
||||
import kotlinx.coroutines.*
|
||||
@@ -55,6 +57,35 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString =
|
||||
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
|
||||
AnnotatedString(".", SpanStyle(fontWeight = w))
|
||||
|
||||
// Must be coordinated with MarkdownText — same text transformations for:
|
||||
// Mention, HyperLink, SimplexLink, Command
|
||||
fun displayText(ci: ChatItem, linkMode: SimplexLinkMode, sendCommandMsg: Boolean): String {
|
||||
val formattedText = ci.formattedText
|
||||
if (formattedText == null) return ci.text
|
||||
return formattedText.joinToString("") { ft ->
|
||||
when (ft.format) {
|
||||
is Format.Mention -> {
|
||||
val mention = ci.mentions?.get(ft.format.memberName)
|
||||
if (mention?.memberRef != null) {
|
||||
val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName
|
||||
else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})"
|
||||
mentionText(name)
|
||||
} else if (mention != null) mentionText(ft.format.memberName)
|
||||
else ft.text
|
||||
}
|
||||
is Format.HyperLink -> ft.format.showText ?: ft.text
|
||||
is Format.SimplexLink -> {
|
||||
val t = ft.format.showText
|
||||
?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null
|
||||
if (t != null) "$t ${ft.format.viaHosts}" else ft.text
|
||||
}
|
||||
is Format.Command -> if (sendCommandMsg) "/${ft.format.commandStr}" else ft.text
|
||||
else -> ft.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text transformations in this function must match displayText above
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
text: CharSequence,
|
||||
@@ -77,7 +108,9 @@ fun MarkdownText (
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
showViaProxy: Boolean = false,
|
||||
showTimestamp: Boolean = true,
|
||||
prefix: AnnotatedString? = null
|
||||
prefix: AnnotatedString? = null,
|
||||
selectionRange: IntRange? = null,
|
||||
onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null
|
||||
) {
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
@@ -126,19 +159,27 @@ fun MarkdownText (
|
||||
)
|
||||
}
|
||||
if (formattedText == null) {
|
||||
var selectableEnd = 0
|
||||
val annotatedText = buildAnnotatedString {
|
||||
inlineContent?.first?.invoke(this)
|
||||
appendSender(this, sender, senderBold)
|
||||
if (prefix != null) append(prefix)
|
||||
if (text is String) append(text)
|
||||
else if (text is AnnotatedString) append(text)
|
||||
selectableEnd = this.length
|
||||
if (meta?.isLive == true) {
|
||||
append(typingIndicator(meta.recent, typingIdx))
|
||||
}
|
||||
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
}
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
|
||||
val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) }
|
||||
if (clampedRange != null) {
|
||||
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult)
|
||||
} else {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
|
||||
}
|
||||
} else {
|
||||
var selectableEnd = 0
|
||||
var hasLinks = false
|
||||
var hasSecrets = false
|
||||
var hasCommands = false
|
||||
@@ -247,6 +288,7 @@ fun MarkdownText (
|
||||
is Format.Unknown -> append(ft.text)
|
||||
}
|
||||
}
|
||||
selectableEnd = this.length
|
||||
if (meta?.isLive == true) {
|
||||
append(typingIndicator(meta.recent, typingIdx))
|
||||
}
|
||||
@@ -255,9 +297,10 @@ fun MarkdownText (
|
||||
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
|
||||
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
}
|
||||
val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) }
|
||||
if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) {
|
||||
val icon = remember { mutableStateOf(PointerIcon.Default) }
|
||||
ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
|
||||
ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
|
||||
onLongClick = { offset ->
|
||||
if (hasLinks) {
|
||||
val withAnnotation: (String, (Range<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 (clampedRange != null) {
|
||||
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult)
|
||||
} else {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,6 +362,7 @@ fun ClickableText(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = TextStyle.Default,
|
||||
selectionRange: IntRange? = null,
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
@@ -354,9 +403,19 @@ fun ClickableText(
|
||||
}
|
||||
}
|
||||
|
||||
val selectionHighlight = if (selectionRange != null) {
|
||||
Modifier.drawBehind {
|
||||
layoutResult.value?.let { result ->
|
||||
if (selectionRange.first < selectionRange.last && selectionRange.last + 1 <= text.length) {
|
||||
drawPath(result.getPathForRange(selectionRange.first, selectionRange.last + 1), SelectionHighlightColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = modifier.then(pressIndicator),
|
||||
modifier = modifier.then(selectionHighlight).then(pressIndicator),
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
@@ -368,6 +427,40 @@ fun ClickableText(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectableText(
|
||||
text: AnnotatedString,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
selectionRange: IntRange? = null,
|
||||
onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null
|
||||
) {
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val highlight = if (selectionRange != null) {
|
||||
Modifier.drawBehind {
|
||||
layoutResult.value?.let { result ->
|
||||
if (selectionRange.first < selectionRange.last && selectionRange.last + 1 <= text.length) {
|
||||
drawPath(result.getPathForRange(selectionRange.first, selectionRange.last + 1), SelectionHighlightColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = modifier.then(highlight),
|
||||
style = style,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
onTextLayout = {
|
||||
layoutResult.value = it
|
||||
onTextLayoutResult?.invoke(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun openBrowserAlert(uri: String, uriHandler: UriHandler) {
|
||||
val (res, err) = sanitizeUri(uri)
|
||||
if (res == null) {
|
||||
|
||||
Reference in new issue
Block a user