This commit is contained in:
Evgeny @ SimpleX Chat
2026-03-31 21:07:25 +00:00
parent b9913397c3
commit 892249143d
9 changed files with 154 additions and 642 deletions

View File

@@ -4379,24 +4379,28 @@ sealed class Format {
@Serializable @SerialName("phone") class Phone: Format()
@Serializable @SerialName("unknown") class Unknown: Format()
fun style(colors: Colors, typography: Typography): SpanStyle = when (this) {
val style: SpanStyle @Composable get() = when (this) {
is Bold -> SpanStyle(fontWeight = FontWeight.Bold)
is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
is Small -> SpanStyle(fontSize = typography.body2.fontSize, color = colors.secondary)
is Colored -> SpanStyle(color = this.color.uiColor(colors))
is Uri, is HyperLink, is SimplexLink, is Email, is Phone -> linkStyle(colors)
is Command -> SpanStyle(color = colors.primary, fontFamily = FontFamily.Monospace)
is Small -> SpanStyle(fontSize = MaterialTheme.typography.body2.fontSize, color = MaterialTheme.colors.secondary)
is Colored -> SpanStyle(color = this.color.uiColor)
is Uri -> linkStyle
is HyperLink -> linkStyle
is SimplexLink -> linkStyle
is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace)
is Mention -> SpanStyle(fontWeight = FontWeight.Medium)
is Email -> linkStyle
is Phone -> linkStyle
is Unknown -> SpanStyle()
}
val isSimplexLink = this is SimplexLink
companion object {
fun linkStyle(colors: Colors) = SpanStyle(color = colors.primary, textDecoration = TextDecoration.Underline)
val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
}
}
@@ -4428,15 +4432,15 @@ enum class FormatColor(val color: String) {
black("black"),
white("white");
fun uiColor(colors: Colors): Color = when (this) {
val uiColor: Color @Composable get() = when (this) {
red -> Color.Red
green -> SimplexGreen
blue -> SimplexBlue
yellow -> WarningYellow
cyan -> Color.Cyan
magenta -> Color.Magenta
black -> colors.onBackground
white -> colors.onBackground
black -> MaterialTheme.colors.onBackground
white -> MaterialTheme.colors.onBackground
}
}

View File

@@ -88,8 +88,7 @@ suspend fun processLoadedChat(
val (newIds, _) = mapItemsToIds(chat.chatItems)
val wasSize = newItems.size
val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination(
unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed,
selectionActive = chatState.selectionActive
unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed
)
val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0)
newItems.addAll(insertAt, chat.chatItems)
@@ -178,14 +177,13 @@ private fun removeDuplicatesAndModifySplitsOnBeforePagination(
newItems: SnapshotStateList<ChatItem>,
newIds: Set<Long>,
splits: StateFlow<List<Long>>,
visibleItemIndexesNonReversed: () -> IntRange,
selectionActive: Boolean = false
visibleItemIndexesNonReversed: () -> IntRange
): ModifiedSplits {
var oldUnreadSplitIndex: Int = -1
var newUnreadSplitIndex: Int = -1
val visibleItemIndexes = visibleItemIndexesNonReversed()
var lastSplitIndexTrimmed = -1
var allowedTrimming = !selectionActive
var allowedTrimming = true
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

View File

@@ -202,9 +202,7 @@ data class ActiveChatState (
// exclusive
val unreadAfter: MutableStateFlow<Int> = MutableStateFlow(0),
// exclusive
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0),
// Desktop text selection: disable item eviction during active selection
@Volatile var selectionActive: Boolean = false
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0)
) {
fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List<ChatItem>) {
toItemId ?: return

View File

@@ -14,7 +14,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.*
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.layout.layoutId
@@ -963,21 +962,11 @@ 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(
LocalSelectionManager provides selectionManager,
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,
@@ -1904,7 +1893,7 @@ fun BoxScope.ChatItemsList(
}
false
}
val swipeableModifier = if (appPlatform.isDesktop) Modifier else SwipeToDismissModifier(
val swipeableModifier = SwipeToDismissModifier(
state = dismissState,
directions = setOf(DismissDirection.EndToStart),
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
@@ -2205,10 +2194,8 @@ fun BoxScope.ChatItemsList(
}
}
val selectionModifier = SelectionHandler(LocalSelectionManager.current, listState, mergedItems, linkMode)
LazyColumnWithScrollBar(
Modifier.align(Alignment.BottomCenter).then(selectionModifier),
Modifier.align(Alignment.BottomCenter),
state = listState.value,
contentPadding = PaddingValues(
top = topPaddingToContent,

View File

@@ -1,311 +0,0 @@
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.buildMsgAnnotatedString
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 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 computeHighlightRange(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
index == hi -> 0 until endOff
else -> 0 until Int.MAX_VALUE
}
}
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 = buildMsgAnnotatedString(
text = ci.text, formattedText = ci.formattedText,
sender = null, senderBold = false, prefix = null,
mentions = ci.mentions, userMemberId = null,
toggleSecrets = false, sendCommandMsg = false, linkMode = linkMode
).text
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")
}
}
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 localY = firstChange.position.y
val idx = resolveIndexAtY(listState.value, localY)
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 localY = change.position.y
val idx = resolveIndexAtY(listState.value, localY)
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? {
val visibleItems = listState.layoutInfo.visibleItemsInfo
return visibleItems.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)
}
}

View File

@@ -1,10 +1,9 @@
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.runtime.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
@@ -13,46 +12,18 @@ 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)
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont)
@Composable
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean, selectionIndex: Int = -1) {
val selectionManager = LocalSelectionManager.current
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 = selectionManager?.computeHighlightRange(selectionIndex) != null
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) {
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(if (isSelected) Modifier.background(SelectionHighlightColor) else Modifier) {
EmojiText(chatItem.content.text)
}
EmojiText(chatItem.content.text)
CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}

View File

@@ -20,10 +20,8 @@ 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 chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers
@@ -368,65 +366,11 @@ fun CIMarkdownText(
onLinkLongClick: (link: String) -> Unit = {},
showViaProxy: Boolean,
showTimestamp: Boolean,
prefix: AnnotatedString? = null,
selectionIndex: Int = -1
prefix: AnnotatedString? = null
) {
val selectionManager = LocalSelectionManager.current
val boundsState = remember { mutableStateOf<Rect?>(null) }
val layoutResultState = remember { mutableStateOf<TextLayoutResult?>(null) }
val chatInfo = chat.chatInfo
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
val contentLength = remember(text, ci.formattedText, ci.mentions) {
buildMsgAnnotatedString(
text = text, formattedText = if (text.isEmpty()) emptyList() else ci.formattedText,
sender = null, senderBold = true, prefix = prefix,
mentions = ci.mentions, userMemberId = when {
chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId
else -> null
},
toggleSecrets = true, sendCommandMsg = chatInfo.useCommands && chat.chatInfo.sndReady,
linkMode = linkMode
).text.length
}
if (selectionManager != null && ci.meta.isLive != true && selectionIndex >= 0) {
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.coerceAtMost(contentLength))
}
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.coerceAtMost(contentLength))
}
}
}
}
val highlightRange = selectionManager?.computeHighlightRange(selectionIndex)
Box(
Modifier
.padding(vertical = 7.dp, horizontal = 12.dp)
.onGloballyPositioned { boundsState.value = it.boundsInWindow() }
) {
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
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,
@@ -435,9 +379,7 @@ fun CIMarkdownText(
chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId
else -> null
},
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix,
selectionRange = highlightRange,
onTextLayoutResult = { layoutResultState.value = it }
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix
)
}
}

View File

@@ -5,12 +5,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme
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.*
@@ -23,7 +22,6 @@ 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.*
@@ -57,123 +55,6 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString =
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
AnnotatedString(".", SpanStyle(fontWeight = w))
fun buildMsgAnnotatedString(
text: CharSequence,
formattedText: List<FormattedText>?,
sender: String?,
senderBold: Boolean,
prefix: AnnotatedString?,
mentions: Map<String, CIMention>?,
userMemberId: String?,
toggleSecrets: Boolean,
showSecrets: Map<String, Boolean> = emptyMap(),
sendCommandMsg: Boolean,
linkMode: SimplexLinkMode,
colors: Colors? = null,
typography: Typography? = null
): AnnotatedString = buildAnnotatedString {
fun styled(format: Format, content: () -> Unit) {
val s = if (colors != null && typography != null) format.style(colors, typography) else null
if (s != null) withStyle(s) { content() } else content()
}
appendSender(this, sender, senderBold)
if (prefix != null) append(prefix)
if (formattedText == null) {
if (text is String) append(text)
else if (text is AnnotatedString) append(text)
} else {
for ((i, ft) in formattedText.withIndex()) {
if (ft.format == null) append(ft.text)
else when(ft.format) {
is Format.Bold, is Format.Italic, is Format.StrikeThrough, is Format.Snippet,
is Format.Small, is Format.Colored -> styled(ft.format) { append(ft.text) }
is Format.Secret -> {
if (toggleSecrets) {
val key = i.toString()
withAnnotation(tag = "SECRET", annotation = key) {
if (showSecrets[key] == true) append(ft.text) else styled(ft.format) { append(ft.text) }
}
} else {
styled(ft.format) { append(ft.text) }
}
}
is Format.Mention -> {
val mention = mentions?.get(ft.format.memberName)
if (mention != null) {
if (mention.memberRef != null) {
val displayName = mention.memberRef.displayName
val name = if (mention.memberRef.localAlias.isNullOrEmpty()) {
displayName
} else {
"${mention.memberRef.localAlias} ($displayName)"
}
val ftStyle = if (colors != null && typography != null) ft.format.style(colors, typography) else null
val mentionStyle = if (ftStyle != null && colors != null && mention.memberId == userMemberId) ftStyle.copy(color = colors.primary) else ftStyle
if (mentionStyle != null) withStyle(mentionStyle) { append(mentionText(name)) } else append(mentionText(name))
} else {
styled(ft.format) { append(mentionText(ft.format.memberName)) }
}
} else {
append(ft.text)
}
}
is Format.Command ->
if (!sendCommandMsg) {
append(ft.text)
} else {
val cmd = ft.format.commandStr
withAnnotation(tag = "COMMAND", annotation = cmd) {
styled(ft.format) { append("/$cmd") }
}
}
is Format.Uri -> {
val s = ft.text
val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s"
withAnnotation(tag = "WEB_URL", annotation = link) {
styled(ft.format) { append(ft.text) }
}
}
is Format.HyperLink -> {
withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) {
styled(ft.format) { append(ft.format.showText ?: ft.text) }
}
}
is Format.SimplexLink -> {
val link =
if (linkMode == SimplexLinkMode.BROWSER && ft.format.showText == null && !ft.text.startsWith("[")) ft.text
else ft.format.simplexUri
val t = ft.format.showText ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null
withAnnotation(tag = "SIMPLEX_URL", annotation = link) {
if (t == null) {
styled(ft.format) { append(ft.text) }
} else {
val ftStyle = if (colors != null && typography != null) ft.format.style(colors, typography) else null
if (ftStyle != null) {
withStyle(ftStyle) { append("$t ") }
withStyle(ftStyle.copy(fontStyle = FontStyle.Italic)) { append(ft.format.viaHosts) }
} else {
append("$t ")
append(ft.format.viaHosts)
}
}
}
}
is Format.Email -> {
withAnnotation(tag = "OTHER_URL", annotation = "mailto:${ft.text}") {
styled(ft.format) { append(ft.text) }
}
}
is Format.Phone -> {
withAnnotation(tag = "OTHER_URL", annotation = "tel:${ft.text}") {
styled(ft.format) { append(ft.text) }
}
}
is Format.Unknown -> append(ft.text)
}
}
}
}
@Composable
fun MarkdownText (
text: CharSequence,
@@ -196,9 +77,7 @@ fun MarkdownText (
onLinkLongClick: (link: String) -> Unit = {},
showViaProxy: Boolean = false,
showTimestamp: Boolean = true,
prefix: AnnotatedString? = null,
selectionRange: IntRange? = null,
onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null
prefix: AnnotatedString? = null
) {
val textLayoutDirection = remember (text) {
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
@@ -246,38 +125,128 @@ fun MarkdownText (
}
)
}
val contentAnnotated = buildMsgAnnotatedString(
text = text, formattedText = formattedText, sender = sender, senderBold = senderBold,
prefix = prefix, mentions = mentions, userMemberId = userMemberId,
toggleSecrets = toggleSecrets, showSecrets = showSecrets,
sendCommandMsg = sendCommandMsg != null, linkMode = linkMode,
colors = MaterialTheme.colors, typography = MaterialTheme.typography
)
val contentLength = contentAnnotated.text.length
val clampedSelectionRange = selectionRange?.let {
it.first until minOf(it.last, contentLength)
}
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
append(contentAnnotated)
appendSender(this, sender, senderBold)
if (prefix != null) append(prefix)
if (text is String) append(text)
else if (text is AnnotatedString) append(text)
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
if (meta?.isLive == true) {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
} else {
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedSelectionRange, onTextLayoutResult = onTextLayoutResult)
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
} else {
val hasLinks = formattedText.any { it.format is Format.Uri || it.format is Format.HyperLink || it.format is Format.SimplexLink || it.format is Format.Email || it.format is Format.Phone }
val hasSecrets = toggleSecrets && formattedText.any { it.format is Format.Secret }
val hasCommands = sendCommandMsg != null && formattedText.any { it.format is Format.Command }
var hasLinks = false
var hasSecrets = false
var hasCommands = false
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
append(contentAnnotated)
appendSender(this, sender, senderBold)
if (prefix != null) append(prefix)
for ((i, ft) in formattedText.withIndex()) {
if (ft.format == null) append(ft.text)
else when(ft.format) {
is Format.Bold -> withStyle(ft.format.style) { append(ft.text) }
is Format.Italic -> withStyle(ft.format.style) { append(ft.text) }
is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) }
is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) }
is Format.Small -> withStyle(ft.format.style) { append(ft.text) }
is Format.Colored -> withStyle(ft.format.style) { append(ft.text) }
is Format.Secret -> {
val ftStyle = ft.format.style
if (toggleSecrets) {
hasSecrets = true
val key = i.toString()
withAnnotation(tag = "SECRET", annotation = key) {
if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) }
}
} else {
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.Mention -> {
val mention = mentions?.get(ft.format.memberName)
if (mention != null) {
val ftStyle = ft.format.style
if (mention.memberRef != null) {
val displayName = mention.memberRef.displayName
val name = if (mention.memberRef.localAlias.isNullOrEmpty()) {
displayName
} else {
"${mention.memberRef.localAlias} ($displayName)"
}
val mentionStyle = if (mention.memberId == userMemberId) ftStyle.copy(color = MaterialTheme.colors.primary) else ftStyle
withStyle(mentionStyle) { append(mentionText(name)) }
} else {
withStyle(ftStyle) { append(mentionText(ft.format.memberName)) }
}
} else {
append(ft.text)
}
}
is Format.Command ->
if (sendCommandMsg == null) {
append(ft.text)
} else {
hasCommands = true
val ftStyle = ft.format.style
val cmd = ft.format.commandStr
withAnnotation(tag = "COMMAND", annotation = cmd) {
withStyle(ftStyle) { append("/$cmd") }
}
}
is Format.Uri -> {
hasLinks = true
val ftStyle = Format.linkStyle
val s = ft.text
val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s"
withAnnotation(tag = "WEB_URL", annotation = link) {
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.HyperLink -> {
hasLinks = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) {
withStyle(ftStyle) { append(ft.format.showText ?: ft.text) }
}
}
is Format.SimplexLink -> {
hasLinks = true
val ftStyle = Format.linkStyle
val link =
if (linkMode == SimplexLinkMode.BROWSER && ft.format.showText == null && !ft.text.startsWith("[")) ft.text
else ft.format.simplexUri
val t = ft.format.showText ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null
withAnnotation(tag = "SIMPLEX_URL", annotation = link) {
if (t == null) {
withStyle(ftStyle) { append(ft.text) }
} else {
withStyle(ftStyle) { append("$t ") }
withStyle(ftStyle.copy(fontStyle = FontStyle.Italic)) { append(ft.format.viaHosts) }
}
}
}
is Format.Email -> {
hasLinks = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "OTHER_URL", annotation = "mailto:${ft.text}") {
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.Phone -> {
hasLinks = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "OTHER_URL", annotation = "tel:${ft.text}") {
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.Unknown -> append(ft.text)
}
}
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
@@ -288,7 +257,7 @@ fun MarkdownText (
}
if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) {
val icon = remember { mutableStateOf(PointerIcon.Default) }
ClickableText(annotatedText, style = style, selectionRange = selectionRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
if (hasLinks) {
val withAnnotation: (String, (Range<String>) -> Unit) -> Unit = { tag, f ->
@@ -331,11 +300,10 @@ 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 {
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = selectionRange, onTextLayoutResult = onTextLayoutResult)
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
}
}
}
@@ -346,7 +314,6 @@ 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,
@@ -387,19 +354,9 @@ 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(selectionHighlight).then(pressIndicator),
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
@@ -411,40 +368,6 @@ 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) {

View File

@@ -3,7 +3,7 @@ package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.stringResource
@@ -38,7 +38,7 @@ fun MarkdownHelpView() {
Row {
MdSyntax("!1 $colored!")
Text(buildAnnotatedString {
withStyle(Format.Colored(FormatColor.red).style(MaterialTheme.colors, MaterialTheme.typography)) { append(colored) }
withStyle(Format.Colored(FormatColor.red).style) { append(colored) }
append(" (")
appendColor(this, "1", FormatColor.red, ", ")
appendColor(this, "2", FormatColor.green, ", ")
@@ -52,7 +52,7 @@ fun MarkdownHelpView() {
MdSyntax("#$secret#")
SelectionContainer {
Text(buildAnnotatedString {
withStyle(Format.Secret().style(MaterialTheme.colors, MaterialTheme.typography)) { append(secret) }
withStyle(Format.Secret().style) { append(secret) }
})
}
}
@@ -72,14 +72,14 @@ fun MdFormat(markdown: String, example: String, format: Format) {
Row {
MdSyntax(markdown)
Text(buildAnnotatedString {
withStyle(format.style(MaterialTheme.colors, MaterialTheme.typography)) { append(example) }
withStyle(format.style) { append(example) }
})
}
}
@Composable
fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: String) {
b.withStyle(Format.Colored(c).style(MaterialTheme.colors, MaterialTheme.typography)) { append(s)}
b.withStyle(Format.Colored(c).style) { append(s)}
b.append(after)
}