Merge branch 'master' into nd/fix-profile-picker-failed-change

This commit is contained in:
Narasimha-sc committed 2026-10-19 11:56:12 +00:00
commit f03ea4c4b2
143 files changed
+9738 -10981

No files matched your search

@@ -52,8 +52,7 @@ import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import java.io.Closeable
@@ -168,13 +167,24 @@ actual fun ActiveCallView() {
CallSoundsPlayer.stop()
}
is WCallResponse.Ice -> withBGApi {
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates)
}
is WCallResponse.Connection ->
try {
// the call is restored and NOT sent to the core
// Makes sure the core DOES NOT record the duration wrong if a reconnect happens
if (r.state.connectionState == "reconnecting") {
if (call.callState != CallState.Reconnecting) {
CallSoundsPlayer.startConnectingCallSound(scope)
}
updateActiveCall(call) { it.copy(callState = CallState.Reconnecting) }
} else try {
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
if (callStatus == WebRTCCallStatus.Connected) {
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) }
if (call.callState == CallState.Reconnecting) {
CallSoundsPlayer.stop()
}
// connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = it.connectedAt ?: Clock.System.now()) }
}
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
} catch (e: Throwable) {
@@ -705,18 +715,17 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
val wv = webView.value
if (wv != null) {
LaunchedEffect(Unit) {
snapshotFlow { callCommand.firstOrNull() }
.distinctUntilChanged()
.filterNotNull()
.collect {
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(wv, cmd)
}
while (isActive) {
// waiting for the first command would miss a command equal to the previous one
snapshotFlow { callCommand.isNotEmpty() }.first { it }
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(wv, cmd)
}
}
}
}
}
val assetLoader = WebViewAssetLoader.Builder()
@@ -3407,6 +3407,7 @@ data class ChatItem (
val meta: CIMeta,
val content: CIContent,
val formattedText: List<FormattedText>? = null,
val markdownBlocks: List<MarkdownBlock>? = null,
val mentions: Map<String, CIMention>? = null,
val quotedItem: CIQuote? = null,
val reactions: List<CIReactionCount>,
@@ -4412,7 +4413,8 @@ class CIQuote (
val sharedMsgId: String? = null,
val sentAt: Instant,
val content: MsgContent,
val formattedText: List<FormattedText>? = null
val formattedText: List<FormattedText>? = null,
val markdownBlocks: List<MarkdownBlock>? = null
): ItemContent {
override val text: String by lazy {
if (content.text == "" && content is MsgContent.MCVoice)
@@ -5164,7 +5166,7 @@ class FormattedText(val text: String, val format: Format? = null) {
val linkUri: String? get() =
when (format) {
is Format.Uri -> text
is Format.HyperLink -> format.linkUri
is Format.HyperLink -> if (format.isEmail) null else format.linkUri
else -> null
}
@@ -5175,7 +5177,7 @@ class FormattedText(val text: String, val format: Format? = null) {
@Serializable
sealed class Format {
@Serializable @SerialName("bold") class Bold: Format()
@Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format()
@Serializable @SerialName("italic") class Italic: Format()
@Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
@Serializable @SerialName("snippet") class Snippet: Format()
@@ -5183,7 +5185,9 @@ sealed class Format {
@Serializable @SerialName("small") class Small: Format()
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format()
@Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() {
val isEmail: Boolean get() = linkUri.startsWith("mailto:")
}
@Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List<String>): Format() {
val simplexLinkText: String get() =
"${linkType.description} $viaHosts"
@@ -5200,6 +5204,8 @@ sealed class Format {
}
@Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format()
@Serializable @SerialName("escape") class Escape: Format()
@Serializable @SerialName("markup") class Markup(val token: BlockToken): Format()
@Serializable @SerialName("unknown") class Unknown: Format()
val style: SpanStyle @Composable get() = when (this) {
@@ -5219,6 +5225,8 @@ sealed class Format {
is Email -> linkStyle
is Phone -> linkStyle
is Modal -> linkStyle
is Escape -> SpanStyle()
is Markup -> SpanStyle()
is Unknown -> SpanStyle()
}
@@ -5229,6 +5237,87 @@ sealed class Format {
}
}
@Serializable
sealed class BlockToken {
@Serializable @SerialName("lineBreak") class LineBreak: BlockToken()
@Serializable @SerialName("softBreak") class SoftBreak: BlockToken()
@Serializable @SerialName("hardBreak") class HardBreak: BlockToken()
@Serializable @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int): BlockToken()
@Serializable @SerialName("indent") class Indent: BlockToken()
@Serializable @SerialName("headingMark") class HeadingMark(val level: Int): BlockToken()
@Serializable @SerialName("headingClose") class HeadingClose: BlockToken()
@Serializable @SerialName("headingUnderline") class HeadingUnderline(val level: Int): BlockToken()
@Serializable @SerialName("listMark") class ListMark(val number: String? = null): BlockToken()
@Serializable @SerialName("quoteMark") class QuoteMark: BlockToken()
@Serializable @SerialName("fenceMark") class FenceMark(val language: String? = null): BlockToken()
@Serializable @SerialName("rowMark") class RowMark: BlockToken()
@Serializable @SerialName("cellMark") class CellMark: BlockToken()
@Serializable @SerialName("delimiterRow") class DelimiterRow(val columns: List<TableAlign>): BlockToken()
@Serializable @SerialName("ruleMark") class RuleMark: BlockToken()
val drawnText: String get() = when (this) {
is SoftBreak -> " "
is LineBreak, is HardBreak -> "\n"
is ParagraphBreak -> "\n".repeat(lineBreaks)
else -> ""
}
val previewText: String get() = when (this) {
is SoftBreak, is HardBreak, is CellMark -> " "
is LineBreak, is ParagraphBreak -> "\n"
is ListMark -> (number ?: "•") + " "
else -> ""
}
}
@Serializable
sealed class MarkdownBlock {
@Serializable @SerialName("paragraph") class Paragraph(val content: SegmentRange): MarkdownBlock()
@Serializable @SerialName("heading") class Heading(val level: Int, val content: SegmentRange): MarkdownBlock()
@Serializable @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange): MarkdownBlock()
@Serializable @SerialName("quote") class Quote(val content: SegmentRange): MarkdownBlock()
@Serializable @SerialName("list") class ItemList(val items: List<ListEntry>): MarkdownBlock()
@Serializable @SerialName("table") class Table(val columns: List<TableAlign>, val header: List<SegmentRange>, val rows: List<List<SegmentRange>>): MarkdownBlock()
@Serializable @SerialName("rule") class Rule(val mark: SegmentRange): MarkdownBlock()
}
@Serializable
class ListEntry(val number: String? = null, val content: SegmentRange, val items: List<ListEntry>)
@Serializable
class SegmentRange(val from: Int, val to: Int)
@Serializable
enum class TableAlign {
@SerialName("none") None,
@SerialName("left") Left,
@SerialName("center") Center,
@SerialName("right") Right
}
class BlockTextRange(val range: SegmentRange, val inRow: Boolean)
val MarkdownBlock.textRanges: List<BlockTextRange> get() = when (this) {
is MarkdownBlock.Paragraph -> listOf(BlockTextRange(content, false))
is MarkdownBlock.Heading -> listOf(BlockTextRange(content, false))
is MarkdownBlock.CodeBlock -> listOf(BlockTextRange(content, false))
is MarkdownBlock.Quote -> listOf(BlockTextRange(content, false))
is MarkdownBlock.ItemList -> listEntries(items, 0).map { (entry, _) -> BlockTextRange(entry.content, false) }
is MarkdownBlock.Table -> (listOf(header) + rows).flatMap { row -> row.mapIndexed { c, cell -> BlockTextRange(cell, c > 0) } }
is MarkdownBlock.Rule -> emptyList()
}
fun listEntries(items: List<ListEntry>, level: Int): List<Pair<ListEntry, Int>> =
items.flatMap { listOf(it to level) + listEntries(it.items, level + 1) }
fun blockTextRanges(blocks: List<MarkdownBlock>): List<BlockTextRange> = blocks.flatMap { it.textRanges }
fun headingSegments(size: Int, blocks: List<MarkdownBlock>): BooleanArray {
val bold = BooleanArray(size)
blocks.forEach { if (it is MarkdownBlock.Heading) for (i in it.content.from.coerceIn(0, size) until it.content.to.coerceIn(0, size)) bold[i] = true }
return bold
}
@Serializable
enum class SimplexLinkType(val linkType: String) {
contact("contact"),
@@ -5714,6 +5803,7 @@ data class ChatItemVersion(
val chatItemVersionId: Long,
val msgContent: MsgContent,
val formattedText: List<FormattedText>?,
val markdownBlocks: List<MarkdownBlock>? = null,
val itemVersionTs: Instant,
val createdAt: Instant,
)
@@ -2185,8 +2185,8 @@ object ChatController {
return r.result is CR.CmdOk
}
suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean {
val extraInfo = WebRTCExtraInfo(rtcIceCandidates)
suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String): Boolean {
val extraInfo = WebRTCExtraInfo(rtcSession, rtcIceCandidates)
val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo))
return r.result is CR.CmdOk
}
@@ -3436,7 +3436,7 @@ object ChatController {
}
is CR.CallExtraInfo -> {
withCall(r, r.contact) { _ ->
chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates))
chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates, session = r.extraInfo.rtcSession))
}
}
is CR.CallEnded -> {
@@ -136,7 +136,12 @@ abstract class NtfManager {
return if (md != null) {
var res = ""
for (ft in md) {
res += if (ft.format is Format.Secret) "..." else ft.text
res += when (ft.format) {
is Format.Secret -> "..."
is Format.Markup -> ft.format.token.previewText
is Format.Escape -> ""
else -> ft.text
}
}
res
} else {
@@ -56,6 +56,7 @@ enum class CallState {
AnswerReceived,
Negotiated,
Connected,
Reconnecting,
Ended;
val text: String get() = when(this) {
@@ -67,6 +68,7 @@ enum class CallState {
AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation)
Negotiated -> generalGetString(MR.strings.callstate_connecting)
Connected -> generalGetString(MR.strings.callstate_connected)
Reconnecting -> generalGetString(MR.strings.callstate_reconnecting)
Ended -> generalGetString(MR.strings.callstate_ended)
}
}
@@ -91,7 +93,7 @@ sealed class WCallCommand {
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallCommand()
@Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
@@ -104,7 +106,7 @@ sealed class WCallResponse {
@Serializable @SerialName("capabilities") data class Capabilities(val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("answer") data class Answer(val answer: String, val iceCandidates: String): WCallResponse()
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallResponse()
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
@Serializable @SerialName("peerMedia") data class PeerMedia(val source: CallMediaSource, val enabled: Boolean): WCallResponse()
@@ -116,7 +118,7 @@ sealed class WCallResponse {
@Serializable data class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
@Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable data class WebRTCExtraInfo(val rtcSession: String? = null, val rtcIceCandidates: String)
@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
@Serializable data class RcvCallInvitation(
val remoteHostId: Long?,
@@ -791,8 +791,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex
val linkMode = chatModel.simplexLinkMode.value
if (descr == null) {
if (short != null) {
val parsed = parseToMarkdownText(short)
MarkdownText(
short, parseToMarkdown(short), toggleSecrets = true, style = style, maxLines = 4,
short, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4,
overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode
)
}
@@ -801,8 +802,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex
val truncated = firstLine.length > 100
val multiline = descr.length > firstLine.length
if (short == null && !truncated && !multiline) {
val parsed = parseToMarkdownText(descr)
MarkdownText(
descr, parseToMarkdown(descr), toggleSecrets = true, style = style, maxLines = 4,
descr, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4,
overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode
)
} else {
@@ -52,17 +52,19 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
val selection = remember { mutableStateOf<CIInfoTab>(CIInfoTab.History) }
@Composable
fun TextBubble(text: String, formattedText: List<FormattedText>?, sender: String?, showMenu: MutableState<Boolean>, mentions: Map<String, CIMention>? = null, userMemberId: String? = null, ) {
fun TextBubble(text: String, formattedText: List<FormattedText>?, markdownBlocks: List<MarkdownBlock>?, sender: String?, showMenu: MutableState<Boolean>, mentions: Map<String, CIMention>? = null, userMemberId: String? = null, ) {
if (text != "") {
MarkdownText(
text, if (text.isEmpty()) emptyList() else formattedText,
text, if (text.isEmpty()) emptyList() else formattedText, markdownBlocks,
sender = sender,
mentions = mentions,
userMemberId = userMemberId,
senderBold = true,
toggleSecrets = true,
linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler,
onLinkLongClick = { showMenu.value = true }
onLinkLongClick = { showMenu.value = true },
blockLayout = true,
cardColor = if (sent) appColors.sentQuote else appColors.receivedQuote
)
} else {
Text(
@@ -85,7 +87,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
.onRightClick { showMenu.value = true }
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions,
TextBubble(text, ciVersion.formattedText, ciVersion.markdownBlocks, sender = null, showMenu = showMenu, mentions = ci.mentions,
userMemberId = when {
chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId
else -> null
@@ -137,7 +139,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
.onRightClick { showMenu.value = true }
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
TextBubble(text, qi.formattedText, sender = qi.sender(null), showMenu)
TextBubble(text, qi.formattedText, qi.markdownBlocks, sender = qi.sender(null), showMenu)
}
}
Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) {
@@ -57,7 +57,7 @@ fun ContextItemView(
}
} else null
MarkdownText(
contextItem.text, contextItem.formattedText,
contextItem.text, contextItem.formattedText, contextItem.markdownBlocks,
sender = null,
toggleSecrets = false,
maxLines = lines,
@@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.item.itemPrefixText
import chat.simplex.common.views.chat.item.itemSegmentDisplayText
import chat.simplex.common.views.chat.item.itemDisplay
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.*
import dev.icerock.moko.resources.compose.painterResource
@@ -267,23 +267,22 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple
if (sel.first < prefix.length) {
sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1))
}
val formattedText = ci.formattedText ?: run {
val parts = itemDisplay(ci, linkMode)?.parts ?: run {
val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length)
val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length)
if (start < end) sb.append(ci.text, start, end)
return sb.toString()
}
var displayOffset = prefix.length
for (ft in formattedText) {
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
for ((segDisplay, source) in parts) {
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)
if (source.length == segDisplay.length) {
sb.append(source, overlapStart - displayOffset, overlapEnd - displayOffset)
} else {
sb.append(ft.text)
sb.append(source)
}
}
displayOffset = displayEnd
@@ -293,12 +292,11 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple
// 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
val parts = itemDisplay(ci, linkMode)?.parts ?: return offset
var displayOffset = itemPrefixText(ci).length
for (ft in formattedText) {
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
for ((segDisplay, source) in parts) {
val displayEnd = displayOffset + segDisplay.length
if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) {
if (offset > displayOffset && offset < displayEnd && source.length != segDisplay.length) {
return if (expandRight) displayEnd else displayOffset
}
displayOffset = displayEnd
@@ -425,18 +423,55 @@ private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? {
return idx
}
class ItemSelection(
class TextSelection(
val highlightRange: IntRange?,
val positionModifier: Modifier,
val onTextLayoutResult: ((TextLayoutResult) -> Unit)?
val onTextLayoutResult: (TextLayoutResult) -> Unit
)
class SelectionText(var base: Int) {
var bounds: Rect? = null
var layout: TextLayoutResult? = null
}
class ItemSelection(
private val highlightRange: IntRange?,
private val texts: MutableMap<Int, SelectionText>?,
private val bases: List<Int>
) {
fun text(index: Int): TextSelection? {
val texts = texts ?: return null
val base = bases.getOrNull(index) ?: return null
val t = texts.getOrPut(index) { SelectionText(base) }
t.base = base
val highlight = highlightRange?.let { (it.first - base).coerceAtLeast(0) .. (it.last - base) }?.takeUnless { it.isEmpty() }
return TextSelection(
highlight,
Modifier.onGloballyPositioned {
val pos = it.positionInWindow()
t.bounds = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height)
},
{ t.layout = it }
)
}
}
private class TextHit(val text: SelectionText, val bounds: Rect, val layout: TextLayoutResult, val offset: Int)
private fun textAt(texts: Collection<SelectionText>, x: Float, y: Float): TextHit? =
texts.mapNotNull { t ->
val b = t.bounds ?: return@mapNotNull null
val l = t.layout?.takeIf { it.layoutInput.text.isNotEmpty() } ?: return@mapNotNull null
Triple(t, b, l)
}.minByOrNull { (_, b, _) -> maxOf(b.top - y, 0f, y - b.bottom) * 4 + maxOf(b.left - x, 0f, x - b.right) }
?.let { (t, b, l) -> TextHit(t, b, l, l.getOffsetForPosition(Offset(x - b.left, y - b.top))) }
// 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) }
fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean, bases: List<Int>): ItemSelection {
val texts = remember { HashMap<Int, SelectionText>() }
texts.keys.retainAll { it < bases.size }
if (selectionManager != null && selectionIndex >= 0 && !isLive) {
val isAnchor = remember(selectionIndex) {
@@ -444,12 +479,8 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int,
}
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 hit = textAt(texts.values, selectionManager.anchorWindowX, selectionManager.anchorWindowY) ?: return@LaunchedEffect
selectionManager.setAnchorOffset(hit.text.base + hit.offset)
}
val isFocus = remember(selectionIndex) {
@@ -459,10 +490,9 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int,
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 hit = textAt(texts.values, px, py) ?: return@collect
val bounds = hit.bounds
val charBox = hit.layout.getBoundingBox(hit.offset.coerceIn(0, hit.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) {
@@ -474,7 +504,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int,
bottom = bounds.top + charBox.bottom - itemWindowY
)
} else Rect.Zero
selectionManager.updateFocusOffset(offset, charRect)
selectionManager.updateFocusOffset(hit.text.base + hit.offset, charRect)
}
}
}
@@ -484,18 +514,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int,
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)
return ItemSelection(highlightRange, if (selectionManager != null) texts else null, bases)
}
// Sets up full-item selection for emoji items (no character-level tracking).
@@ -146,13 +146,16 @@ private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boole
val uriHandler = LocalUriHandler.current
Column {
SelectionContainer(Modifier.fillMaxWidth()) {
val parsed = if (markdown) remember(text) { parseToMarkdownText(text) } else null
MarkdownText(
text,
formattedText = if (markdown) remember(text) { parseToMarkdown(text) } else null,
formattedText = parsed?.formattedText,
markdownBlocks = parsed?.markdownBlocks,
toggleSecrets = false,
modifier = Modifier.fillMaxHeight().padding(horizontal = DEFAULT_PADDING),
linkMode = linkMode, uriHandler = uriHandler,
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp)
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp),
blockLayout = true
)
}
}
@@ -61,6 +61,7 @@ fun FramedItemView(
MarkdownText(
qi.text,
qi.formattedText,
qi.markdownBlocks,
toggleSecrets = true,
maxLines = lines,
overflow = TextOverflow.Ellipsis,
@@ -457,11 +458,12 @@ fun CIMarkdownText(
) {
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)
val bases = remember(ci, linkMode) { itemDisplay(ci, linkMode)?.bases ?: listOf(0) }
val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true, bases)
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) {
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
MarkdownText(
text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (text.isEmpty()) null else ci.markdownBlocks, toggleSecrets = true,
sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
mentions = ci.mentions, userMemberId = when {
@@ -470,8 +472,9 @@ fun CIMarkdownText(
},
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix,
stripLink = stripLink,
selectionRange = selection.highlightRange,
onTextLayoutResult = selection.onTextLayoutResult
selection = selection,
blockLayout = true,
cardColor = if (ci.chatDir.sent) MaterialTheme.appColors.sentQuote else MaterialTheme.appColors.receivedQuote
)
}
}
@@ -0,0 +1,248 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.takeOrElse
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun MarkdownBlocks(
formattedText: List<FormattedText>,
blocks: List<MarkdownBlock>,
style: TextStyle,
modifier: Modifier,
cardColor: Color,
blockText: @Composable (index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier, softWrap: Boolean, last: Boolean) -> Unit,
metaLine: @Composable () -> Unit
) {
val roundness = remember { appPreferences.chatItemRoundness.state }.value.coerceIn(0f, 1f)
val cardShape = RoundedCornerShape(12.dp * roundness)
val lineColor = MaterialTheme.colors.secondary.copy(alpha = 0.35f)
val textColor = style.color.takeOrElse { MaterialTheme.colors.onSurface }
val fontSize = style.fontSize.takeOrElse { 16.sp }
val lineHeight = style.lineHeight.takeOrElse { 22.sp }
val starts = remember(blocks) { blocks.runningFold(0) { n, b -> n + b.textRanges.size } }
val lastIsText = when (blocks.lastOrNull()) {
is MarkdownBlock.Paragraph, is MarkdownBlock.Heading, is MarkdownBlock.Quote, is MarkdownBlock.ItemList -> true
else -> false
}
@Composable
fun BlockText(index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier = Modifier, softWrap: Boolean = true) {
blockText(index, range, style, modifier, softWrap, lastIsText && index == starts.last() - 1)
}
Column(modifier) {
blocks.forEachIndexed { k, block ->
if (k > 0 && paragraphBreak(formattedText, blocks[k - 1].end, block.start)) {
Spacer(Modifier.height(with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() }))
}
val start = starts[k]
when (block) {
is MarkdownBlock.Paragraph -> BlockText(start, block.content, style)
is MarkdownBlock.Heading -> {
val scale = when (block.level) {
1 -> 1.5f
2 -> 1.3f
3 -> 1.15f
else -> 1f
}
BlockText(start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold))
}
is MarkdownBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) {
Box(Modifier.width(4.dp).fillMaxHeight().clip(RoundedCornerShape(2.dp)).background(textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f)))
Spacer(Modifier.width(10.dp))
BlockText(start, block.content, style.copy(color = textColor.copy(alpha = 0.7f)))
}
is MarkdownBlock.ItemList -> listEntries(block.items, 0).forEachIndexed { j, (entry, level) ->
Row(Modifier.padding(start = 20.dp * level)) {
Text(entry.number ?: "•", Modifier.widthIn(min = 14.dp), style = style)
Spacer(Modifier.width(6.dp))
BlockText(start + j, entry.content, style, Modifier.weight(1f, fill = false))
}
}
is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content)) { m ->
BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = fontSize * 0.875f, lineHeight = lineHeight * 0.9f), m, softWrap = false)
}
is MarkdownBlock.Table -> {
val tableRows = listOf(block.header) + block.rows
val rowStarts = tableRows.runningFold(start) { n, row -> n + row.size }
Box(Modifier.horizontalScroll(rememberScrollState())) {
TableGrid(
block.columns, tableRows.map { it.size }, cardColor, MaterialTheme.colors.secondary.copy(alpha = 0.1f), lineColor,
Modifier.clip(cardShape).border(1.dp, lineColor, cardShape)
) {
tableRows.forEachIndexed { r, row ->
row.forEachIndexed { c, cell ->
BlockText(
rowStarts[r] + c, cell, if (r == 0) style.copy(fontWeight = FontWeight.Bold) else style,
Modifier.padding(horizontal = 12.dp, vertical = 4.dp), softWrap = false
)
}
}
}
}
}
is MarkdownBlock.Rule -> Box(Modifier.padding(vertical = 6.dp).fillMaxWidth().height(1.dp).background(lineColor))
}
}
if (!lastIsText) metaLine()
}
}
private val MarkdownBlock.start: Int get() = when (this) {
is MarkdownBlock.Rule -> mark.from
else -> textRanges.firstOrNull()?.range?.from ?: 0
}
private val MarkdownBlock.end: Int get() = when (this) {
is MarkdownBlock.Rule -> mark.to
else -> textRanges.lastOrNull()?.range?.to ?: 0
}
private fun paragraphBreak(fts: List<FormattedText>, from: Int, to: Int): Boolean =
(from.coerceIn(0, fts.size) until to.coerceIn(0, fts.size)).any { (fts[it].format as? Format.Markup)?.token is BlockToken.ParagraphBreak }
private fun codeText(fts: List<FormattedText>, r: SegmentRange): String =
(r.from.coerceIn(0, fts.size) until r.to.coerceIn(0, fts.size)).joinToString("") { i ->
when (val f = fts[i].format) {
is Format.Markup -> f.token.drawnText
is Format.Escape -> ""
else -> fts[i].text
}
}
@Composable
private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, content: @Composable (Modifier) -> Unit) {
var tapped by remember { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val hovered by interactionSource.collectIsHoveredAsState()
val clipboard = LocalClipboardManager.current
Box(
Modifier
.fillMaxWidth()
.clip(shape)
.background(color)
.hoverable(interactionSource)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
if (waitForUpOrCancellation() != null) tapped = !tapped
}
}
) {
content(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp))
if (hovered || tapped) {
Box(Modifier.matchParentSize(), contentAlignment = Alignment.TopEnd) {
Box(
Modifier
.padding(4.dp)
.size(28.dp)
.clip(RoundedCornerShape(8.dp * roundness))
.background(color)
.clickable {
clipboard.setText(AnnotatedString(copyText))
showToast(generalGetString(MR.strings.copied))
},
contentAlignment = Alignment.Center
) {
Icon(painterResource(MR.images.ic_content_copy), stringResource(MR.strings.copy_verb), Modifier.size(18.dp), tint = MaterialTheme.colors.primary)
}
}
}
}
}
private class TableGridLines {
var xs: List<Int> = emptyList()
var ys: List<Int> = emptyList()
}
@Composable
private fun TableGrid(
columns: List<TableAlign>,
rowSizes: List<Int>,
headerColor: Color,
stripeColor: Color,
lineColor: Color,
modifier: Modifier,
content: @Composable () -> Unit
) {
val lines = remember { TableGridLines() }
Layout(
content,
modifier.drawBehind {
val xs = lines.xs
val ys = lines.ys
if (ys.size < 2) return@drawBehind
drawRect(headerColor, Offset.Zero, Size(size.width, ys[1].toFloat()))
for (r in 2 until ys.size - 1 step 2) {
drawRect(stripeColor, Offset(0f, ys[r].toFloat()), Size(size.width, (ys[r + 1] - ys[r]).toFloat()))
}
val stroke = 1.dp.toPx()
xs.drop(1).dropLast(1).forEach { x -> drawLine(lineColor, Offset(x.toFloat(), 0f), Offset(x.toFloat(), size.height), stroke) }
ys.drop(1).dropLast(1).forEach { y -> drawLine(lineColor, Offset(0f, y.toFloat()), Offset(size.width, y.toFloat()), stroke) }
}
) { measurables, _ ->
val cells = rowSizes.flatMapIndexed { r, n -> List(n) { c -> r to c } }.take(measurables.size)
val widths = IntArray(maxOf(columns.size, rowSizes.maxOrNull() ?: 0))
cells.forEachIndexed { k, (_, c) -> widths[c] = maxOf(widths[c], measurables[k].maxIntrinsicWidth(Constraints.Infinity)) }
val placeables = cells.mapIndexed { k, (_, c) -> measurables[k].measure(Constraints(maxWidth = widths[c])) }
val heights = IntArray(rowSizes.size)
placeables.forEachIndexed { k, p -> heights[cells[k].first] = maxOf(heights[cells[k].first], p.height) }
val xs = widths.toList().runningFold(0) { a, w -> a + w }
val ys = heights.toList().runningFold(0) { a, h -> a + h }
lines.xs = xs
lines.ys = ys
layout(xs.last(), ys.last()) {
placeables.forEachIndexed { k, p ->
val (r, c) = cells[k]
val free = widths[c] - p.width
val x = xs[c] + when (columns.getOrNull(c)) {
TableAlign.Right -> free
TableAlign.Center -> free / 2
else -> 0
}
p.place(x, ys[r])
}
}
}
}
@@ -12,8 +12,20 @@ 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.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.*
@@ -22,11 +34,14 @@ import androidx.compose.ui.text.AnnotatedString.Range
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.*
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
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.ui.theme.DEFAULT_PADDING
import chat.simplex.common.ui.theme.appColors
import chat.simplex.common.views.chat.ItemSelection
import chat.simplex.common.views.chat.SelectionHighlightColor
import chat.simplex.common.views.helpers.*
import chat.simplex.res.*
@@ -53,14 +68,17 @@ private fun showFullProfileDescription(description: String) {
ModalManager.end.showModalCloseable { _ ->
ColumnWithScrollBar {
AppBarTitle(generalGetString(MR.strings.profile_description__field))
val parsed = parseToMarkdownText(description)
MarkdownText(
description,
parseToMarkdown(description),
parsed?.formattedText,
parsed?.markdownBlocks,
toggleSecrets = true,
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp),
uriHandler = LocalUriHandler.current,
linkMode = chatModel.simplexLinkMode.value,
modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING),
blockLayout = true,
)
}
}
@@ -85,10 +103,10 @@ 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 =
fun segmentDisplayText(ft: FormattedText, mentions: Map<String, CIMention>?, linkMode: SimplexLinkMode): String =
when (ft.format) {
is Format.Mention -> {
val mention = ci.mentions?.get(ft.format.memberName)
val mention = 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})"
@@ -104,13 +122,37 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin
if (t != null) "$t ${ft.format.viaHosts}" else ft.text
}
is Format.Command -> ft.text
is Format.Escape -> ""
is Format.Markup -> ft.format.token.drawnText
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) }
class ItemDisplay(val parts: List<Pair<String, String>>, val bases: List<Int>)
fun itemDisplay(ci: ChatItem, linkMode: SimplexLinkMode): ItemDisplay? {
val fts = ci.formattedText ?: return null
val segment = { ft: FormattedText ->
val display = segmentDisplayText(ft, ci.mentions, linkMode)
display to if (ft.format is Format.Markup) display else ft.text
}
val blocks = ci.markdownBlocks ?: return ItemDisplay(fts.map(segment), listOf(0))
val parts = mutableListOf<Pair<String, String>>()
var offset = itemPrefixText(ci).length
val bases = blockTextRanges(blocks).mapIndexed { i, t ->
if (i > 0) {
val separator = if (t.inRow) "\t" else "\n"
parts += separator to separator
offset += 1
}
val base = if (i == 0) 0 else offset
for (j in t.range.from.coerceIn(0, fts.size) until t.range.to.coerceIn(0, fts.size)) {
val part = segment(fts[j])
parts += part
offset += part.first.length
}
base
}
return ItemDisplay(parts, bases)
}
// Display-only prefix rendered before ci.text (e.g. "Spam: " for reports).
@@ -120,11 +162,12 @@ fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent)
else -> ""
}
// Text transformations in MarkdownText must match itemSegmentDisplayText above
// Text transformations in MarkdownText must match segmentDisplayText above
@Composable
fun MarkdownText (
text: CharSequence,
formattedText: List<FormattedText>? = null,
markdownBlocks: List<MarkdownBlock>? = null,
sender: String? = null,
meta: CIMeta? = null,
chatTTL: Int? = null,
@@ -145,15 +188,44 @@ fun MarkdownText (
showTimestamp: Boolean = true,
prefix: AnnotatedString? = null,
stripLink: String? = null,
selectionRange: IntRange? = null,
onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null
selection: ItemSelection? = null,
textIndex: Int = 0,
blockLayout: Boolean = false,
range: IntRange? = null,
softWrap: Boolean = true,
cardColor: Color? = null,
parentDirection: LayoutDirection? = null
) {
val text = if (stripLink != null) stripTextLink(text.toString(), stripLink) else text
val formattedText = if (stripLink != null) stripFormattedTextLink(formattedText, stripLink) else formattedText
val textLayoutDirection = remember (text) {
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
}
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
if (blockLayout && formattedText != null && markdownBlocks != null && range == null) {
val direction = LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides textLayoutDirection) {
MarkdownBlocks(
formattedText, markdownBlocks, style, modifier, cardColor ?: MaterialTheme.appColors.receivedQuote,
blockText = { index, r, blockStyle, blockModifier, blockSoftWrap, last ->
MarkdownText(
text, formattedText, markdownBlocks, sender = if (index == 0) sender else null, meta = if (last) meta else null, chatTTL = chatTTL,
mentions = mentions, userMemberId = userMemberId, toggleSecrets = toggleSecrets, sendCommandMsg = sendCommandMsg, style = blockStyle,
uriHandler = uriHandler, senderBold = senderBold, modifier = blockModifier, linkMode = linkMode, onLinkLongClick = onLinkLongClick,
showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = if (index == 0) prefix else null,
selection = selection, textIndex = index, range = r.from until r.to, softWrap = blockSoftWrap, parentDirection = direction
)
},
metaLine = {
MarkdownText(
text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, toggleSecrets = toggleSecrets, style = style,
linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY
)
}
)
}
return
}
val reserve = if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current) && meta != null) {
"\n"
} else if (meta != null) {
reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
@@ -196,6 +268,7 @@ fun MarkdownText (
}
)
}
val textSelection = selection?.text(textIndex)
if (formattedText == null) {
var selectableEnd = 0
val annotatedText = buildAnnotatedString {
@@ -210,9 +283,9 @@ fun MarkdownText (
}
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
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)
val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() }
if (textSelection != null) {
SelectableText(annotatedText, style = style, modifier = modifier.then(textSelection.positionModifier), maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = textSelection.onTextLayoutResult)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
}
@@ -222,29 +295,38 @@ fun MarkdownText (
var hasSecrets = false
var hasCommands = false
var hasModals = false
val hiddenSecrets = mutableListOf<IntRange>()
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
appendSender(this, sender, senderBold)
if (prefix != null) append(prefix)
for ((i, ft) in formattedText.withIndex()) {
val headings = if (range == null && markdownBlocks != null) headingSegments(formattedText.size, markdownBlocks) else null
fun appendSecret(s: String) {
val start = length
withStyle(hiddenSecretStyle) { append(s) }
hiddenSecrets += start until length
}
for (i in range?.let { it.first.coerceAtLeast(0) .. minOf(it.last, formattedText.lastIndex) } ?: formattedText.indices) {
val ft = formattedText[i]
val heading = headings?.get(i) == true
if (heading) pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
if (ft.format == null) append(ft.text)
else when(ft.format) {
is Format.Bold -> withStyle(ft.format.style) { append(ft.text) }
is Format.Bold -> withStyle(if (markdownBlocks != null && ft.format.singleStar) Format.Italic().style else 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) }
if (showSecrets[key] == true) append(ft.text) else appendSecret(ft.text)
}
} else {
withStyle(ftStyle) { append(ft.text) }
appendSecret(ft.text)
}
}
is Format.Mention -> {
@@ -290,7 +372,7 @@ fun MarkdownText (
is Format.HyperLink -> {
hasLinks = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) {
withAnnotation(tag = if (ft.format.isEmail) "OTHER_URL" else "WEB_URL", annotation = ft.format.linkUri) {
withStyle(ftStyle) { append(ft.format.showText ?: ft.text) }
}
}
@@ -338,8 +420,15 @@ fun MarkdownText (
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.Escape -> {}
is Format.Markup -> append(
if (range != null) ft.format.token.drawnText
else if (markdownBlocks != null) ft.format.token.previewText
else ft.text
)
is Format.Unknown -> append(ft.text)
}
if (heading) pop()
}
selectableEnd = this.length
if (meta?.isLive == true) {
@@ -350,10 +439,21 @@ 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) }
val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() }
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val onTextLayout: (TextLayoutResult) -> Unit = { r ->
layoutResult.value = r
textSelection?.onTextLayoutResult?.invoke(r)
}
val textModifier = modifier
.then(textSelection?.positionModifier ?: Modifier)
.then(if (hiddenSecrets.isEmpty()) Modifier else Modifier.drawWithContent {
drawContent()
drawSecretPattern(hiddenSecrets, layoutResult.value)
})
if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null) || hasModals) {
val icon = remember { mutableStateOf(PointerIcon.Text) }
ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = textModifier.pointerHoverIcon(icon.value), softWrap = softWrap, maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
if (hasLinks) {
val withAnnotation: (String, (Range<String>) -> Unit) -> Unit = { tag, f ->
@@ -410,13 +510,38 @@ fun MarkdownText (
|| annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset)
|| annotatedText.hasStringAnnotations(tag = "MODAL", start = offset, end = offset)
},
onTextLayout = { onTextLayoutResult?.invoke(it) }
onTextLayout = onTextLayout
)
} else {
if (onTextLayoutResult != null) {
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult)
if (textSelection != null) {
SelectableText(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayout)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
Text(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf(), onTextLayout = onTextLayout)
}
}
}
}
}
private val hiddenSecretStyle = SpanStyle(color = Color.Transparent)
private val secretTile: ImageBitmap? by lazy { MR.images.vertical_logo.toComposeImageBitmap() }
private fun ContentDrawScope.drawSecretPattern(ranges: List<IntRange>, layout: TextLayoutResult?) {
val tile = secretTile ?: return
if (layout == null) return
val brush = ShaderBrush(ImageShader(tile, TileMode.Repeated, TileMode.Repeated))
val tileScale = 8.dp.toPx() / tile.width
val saturation = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.65f) })
val textLength = layout.layoutInput.text.length
ranges.forEach { r ->
val path = layout.getPathForRange(r.first.coerceIn(0, textLength), (r.last + 1).coerceIn(0, textLength))
val bounds = path.getBounds()
val side = (bounds.width + bounds.height) / tileScale
clipPath(path) {
rotate(45f, bounds.center) {
scale(tileScale, bounds.center) {
drawRect(brush, Offset(bounds.center.x - side / 2, bounds.center.y - side / 2), Size(side, side), alpha = 0.35f, colorFilter = saturation)
}
}
}
@@ -500,6 +625,7 @@ private fun SelectableText(
text: AnnotatedString,
style: TextStyle,
modifier: Modifier = Modifier,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
selectionRange: IntRange? = null,
@@ -511,6 +637,7 @@ private fun SelectableText(
text = text,
modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)),
style = style,
softWrap = softWrap,
maxLines = maxLines,
overflow = overflow,
onTextLayout = {
@@ -624,7 +751,9 @@ fun stripFormattedTextLink(ft: List<FormattedText>?, link: String): List<Formatt
val result = ft.toMutableList()
result.removeAt(result.lastIndex)
val i = result.lastIndex
if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) {
if (i >= 0 && result[i].format is Format.Markup) {
result.removeAt(i)
} else if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) {
result[i] = FormattedText(result[i].text.dropLast(1), null)
if (result[i].text.isEmpty()) result.removeAt(result.lastIndex)
}
@@ -277,6 +277,7 @@ fun ChatPreviewView(
MarkdownText(
text,
formattedText,
if (formattedText == null) null else ci.markdownBlocks,
sender = when {
cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName
else -> null
@@ -132,13 +132,16 @@ fun PlainTextEditor(
@Serializable
data class ParsedFormattedText(
val formattedText: List<FormattedText>? = null
val formattedText: List<FormattedText>? = null,
val markdownBlocks: List<MarkdownBlock>? = null
)
fun parseToMarkdown(text: String): List<FormattedText>? {
fun parseToMarkdown(text: String): List<FormattedText>? = parseToMarkdownText(text)?.formattedText
fun parseToMarkdownText(text: String): ParsedFormattedText? {
val formatted = chatParseMarkdown(text)
return try {
json.decodeFromString(ParsedFormattedText.serializer(), formatted).formattedText
json.decodeFromString(ParsedFormattedText.serializer(), formatted)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse into markdown: " + e.stackTraceToString())
null
@@ -1301,6 +1301,7 @@
<string name="callstate_received_answer">received answer…</string>
<string name="callstate_received_confirmation">received confirmation…</string>
<string name="callstate_connecting">connecting…</string>
<string name="callstate_reconnecting">reconnecting…</string>
<string name="callstate_connected">connected</string>
<string name="callstate_ended">ended</string>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -54,6 +54,8 @@ let inactiveCallMediaSources = {
let activeCall;
let notConnectedCall;
let answerTimeout = 30000;
let reconnectTimeout = 30000;
let iceRestartInterval = 10000;
var useWorker = false;
var isDesktop = false;
var localizedState = "";
@@ -75,7 +77,8 @@ const processCommand = (function () {
return {
peerConnectionConfig: {
iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers,
iceCandidatePoolSize: 10,
// Chrome gathers no candidates on ICE restart when candidates pool is used
iceCandidatePoolSize: 0,
encodedInsertableStreams,
iceTransportPolicy: relay ? "relay" : "all",
// needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers
@@ -202,10 +205,14 @@ const processCommand = (function () {
layout: (_c = notConnectedCall === null || notConnectedCall === void 0 ? void 0 : notConnectedCall.layout) !== null && _c !== void 0 ? _c : LayoutType.Default,
cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0,
peerHasOldVersion: false,
receiveRestartSession,
};
localOrPeerMediaSourcesChanged(call);
await setupMediaStreams(call);
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
let reconnectingTimeout;
let iceRestartTimeout;
let wasConnected = false;
if (pc.connectionState) {
pc.addEventListener("connectionstatechange", connectionStateChange);
}
@@ -216,30 +223,49 @@ const processCommand = (function () {
async function connectionStateChange() {
// "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView)
// See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70)
if (pc.connectionState !== "failed")
if (pc.connectionState !== "failed" || wasConnected)
connectionHandler();
}
async function connectionHandler() {
async function connectionHandler(reconnectTimedOut = false) {
var _a;
let state = (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking"
? pc.iceConnectionState
: pc.iceConnectionState == "completed"
? "connected"
: "connecting"); /* webView 69-70 doesn't have connectionState yet */
if (wasConnected) {
if (reconnectTimedOut) {
reconnectingTimeout = undefined;
if (activeCall !== call || state == "connected")
return;
state = "disconnected";
}
else if (state == "disconnected" || state == "failed") {
if (reconnectingTimeout !== undefined)
return;
reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout);
if (isIceRestarter())
restartIce();
state = "reconnecting";
}
else if (state == "connecting" || state == "new") {
return;
}
}
sendMessageToNative({
resp: {
type: "connection",
state: {
connectionState: (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking"
? pc.iceConnectionState
: pc.iceConnectionState == "completed"
? "connected"
: "connecting") /* webView 69-70 doesn't have connectionState yet */,
connectionState: state,
iceConnectionState: pc.iceConnectionState,
iceGatheringState: pc.iceGatheringState,
signalingState: pc.signalingState,
},
},
});
if (pc.connectionState == "disconnected" ||
pc.connectionState == "failed" ||
(!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed"))) {
if (state == "disconnected" || state == "failed") {
clearConnectionTimeout();
clearReconnecting();
if (pc.connectionState) {
pc.removeEventListener("connectionstatechange", connectionStateChange);
}
@@ -251,8 +277,10 @@ const processCommand = (function () {
}
endCall();
}
else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) {
else if (state == "connected") {
clearConnectionTimeout();
clearReconnecting();
wasConnected = true;
const stats = (await pc.getStats());
for (const stat of stats.values()) {
const { type, state } = stat;
@@ -278,6 +306,53 @@ const processCommand = (function () {
connectionTimeout = undefined;
}
}
function clearReconnecting() {
clearTimeout(reconnectingTimeout);
reconnectingTimeout = undefined;
clearTimeout(iceRestartTimeout);
iceRestartTimeout = undefined;
}
// Only the party that sent the initial offer restarts ICE, so that restart offers never collide
function isIceRestarter() {
var _a;
return ((_a = pc.localDescription) === null || _a === void 0 ? void 0 : _a.type) == "offer";
}
async function restartIce() {
if (activeCall !== call || reconnectingTimeout === undefined)
return;
iceRestartTimeout = setTimeout(restartIce, iceRestartInterval);
// the previous restart offer is not answered yet
if (pc.signalingState != "stable")
return;
try {
const iceCandidates = getIceCandidates(pc, config);
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
sendMessageToNative({ resp: { type: "ice", session: serialize(offer), iceCandidates: await iceCandidates } });
}
catch (e) {
console.log("ICE restart error", e);
}
}
async function receiveRestartSession(session, iceCandidates) {
const desc = parse(session);
const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc));
if (desc.type == "offer" && !isIceRestarter()) {
await pc.setRemoteDescription(remoteDesc);
const localIceCandidates = getIceCandidates(pc, config);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
addIceCandidates(pc, iceCandidates);
sendMessageToNative({ resp: { type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates } });
}
else if (desc.type == "answer" && pc.signalingState == "have-local-offer") {
await pc.setRemoteDescription(remoteDesc);
addIceCandidates(pc, iceCandidates);
}
else {
throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`);
}
}
}
function serialize(x) {
return LZString.compressToBase64(JSON.stringify(x));
@@ -296,6 +371,8 @@ const processCommand = (function () {
console.log("starting outgoing call - capabilities");
if (activeCall)
endCall();
// Stop a preview stream from an earlier pre-connect outgoing call being replaced (activeCall may be null here)
stopNotConnectedCall();
let localStream = null;
try {
localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video && (await browserHasCamera()), VideoCamera.User);
@@ -332,7 +409,8 @@ const processCommand = (function () {
if (activeCall)
endCall();
// It can be already defined on Android when switching calls (if the previous call was outgoing)
notConnectedCall = undefined;
// Stop its preview tracks before clearing, otherwise camera/mic stay live
stopNotConnectedCall();
inactiveCallMediaSources.mic = true;
inactiveCallMediaSources.camera = command.media == CallMediaType.Video;
inactiveCallMediaSourcesChanged(inactiveCallMediaSources);
@@ -436,7 +514,11 @@ const processCommand = (function () {
break;
case "ice":
const remoteIceCandidates = parse(command.iceCandidates);
if (pc) {
if (activeCall && command.session) {
await activeCall.receiveRestartSession(command.session, remoteIceCandidates);
resp = { type: "ok" };
}
else if (pc) {
addIceCandidates(pc, remoteIceCandidates);
resp = { type: "ok" };
}
@@ -1131,6 +1213,13 @@ const processCommand = (function () {
activeCall.localScreenStream.getTracks().forEach((track) => track.stop());
}
}
// Call on any path that abandons notConnectedCall, otherwise its preview camera/mic tracks stay live.
function stopNotConnectedCall() {
if (notConnectedCall) {
notConnectedCall.localStream.getTracks().forEach((track) => track.stop());
notConnectedCall = undefined;
}
}
function resetVideoElements() {
const videos = getVideoElements();
if (!videos)
@@ -2,7 +2,7 @@
// Override defaults to enable worker on Chrome and Safari
useWorker = typeof window.Worker !== "undefined";
isDesktop = true;
// Create WebSocket connection.
// Create WebSocket connection. location.search carries the per-call ?token=... capability required by the server.
const socket = new WebSocket(`ws://${location.host}${location.search}`);
socket.addEventListener("open", (_event) => {
console.log("Opened socket");
@@ -192,4 +192,4 @@ function updateCallInfoView(state, description) {
document.getElementById("state").innerText = state;
document.getElementById("description").innerText = description;
}
//# sourceMappingURL=ui.js.map
//# sourceMappingURL=ui.js.map
@@ -0,0 +1,133 @@
package chat.simplex.app
import chat.simplex.common.model.*
import chat.simplex.common.views.chat.item.itemDisplay
import chat.simplex.common.views.chat.item.segmentDisplayText
import chat.simplex.common.views.helpers.ParsedFormattedText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
class MarkdownBlocksTest {
private val headingAndParagraph = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"Title "},{"format":{"type":"bold","singleStar":true},"text":"x"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"text":"Para a"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"text":"b"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":" \n"},{"text":"c"}],"markdownBlocks":[{"type":"heading","level":1,"content":{"from":1,"to":3}},{"type":"paragraph","content":{"from":4,"to":9}}]}""")
private val nestedList = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"listMark"}},"text":"- "},{"text":"x"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"indent"}},"text":" "},{"text":"y"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"listMark"}},"text":" - "},{"text":"z"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"listMark","number":"2."}},"text":"2. "},{"text":"w"}],"markdownBlocks":[{"type":"list","items":[{"content":{"from":1,"to":5},"items":[{"content":{"from":7,"to":8},"items":[]}]},{"number":"2.","content":{"from":10,"to":11},"items":[]}]}]}""")
private val quote = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q1"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q2"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":">"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"format":{"type":"bold","singleStar":false},"text":"q3"}],"markdownBlocks":[{"type":"quote","content":{"from":1,"to":10}}]}""")
private val codeBlockAndRule = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"fenceMark","language":"bash"}},"text":"```bash"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"ls -l"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"pwd"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"fenceMark"}},"text":"```"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"ruleMark"}},"text":"---"}],"markdownBlocks":[{"type":"codeBlock","language":"bash","content":{"from":2,"to":6}},{"type":"rule","mark":{"from":9,"to":10}}]}""")
private val tableAligned = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"a"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"b"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"c"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"d"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"delimiterRow","columns":["none","left","right","center"]}},"text":"|---|:--|--:|:-:|"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"xxx"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"format":{"type":"bold","singleStar":false},"text":"yyy"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"zzz"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"}],"markdownBlocks":[{"type":"table","columns":["none","left","right","center"],"header":[{"from":1,"to":2},{"from":3,"to":4},{"from":5,"to":6},{"from":7,"to":8}],"rows":[[{"from":13,"to":14},{"from":15,"to":16},{"from":17,"to":18}]]}]}""")
private val gaps = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}},"text":"\n\n"},{"text":"a"},{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":3}},"text":"\n\n\n"},{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"h"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"}],"markdownBlocks":[{"type":"paragraph","content":{"from":1,"to":2}},{"type":"heading","level":1,"content":{"from":4,"to":5}}]}""")
private val escapes = parsed("""{"formattedText":[{"text":"text "},{"format":{"type":"escape"},"text":"\\"},{"text":"*not"},{"format":{"type":"escape"},"text":"\\"},{"text":"*\nmore"}]}""")
@Test
fun testDecodeTokens() {
val tokens = (headingAndParagraph.formattedText!! + nestedList.formattedText!! + codeBlockAndRule.formattedText!! + tableAligned.formattedText!! + gaps.formattedText!!)
.mapNotNull { (it.format as? Format.Markup)?.token }
assertEquals(1, tokens.filterIsInstance<BlockToken.HeadingMark>().first().level)
assertEquals(listOf(null, null, "2."), tokens.filterIsInstance<BlockToken.ListMark>().map { it.number })
assertEquals(listOf("bash", null), tokens.filterIsInstance<BlockToken.FenceMark>().map { it.language })
assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), tokens.filterIsInstance<BlockToken.DelimiterRow>().first().columns)
assertEquals(listOf(2, 3), tokens.filterIsInstance<BlockToken.ParagraphBreak>().map { it.lineBreaks })
listOf(
BlockToken.LineBreak::class, BlockToken.SoftBreak::class, BlockToken.HardBreak::class, BlockToken.Indent::class,
BlockToken.RowMark::class, BlockToken.CellMark::class, BlockToken.RuleMark::class
).forEach { kind -> assert(tokens.any { kind.isInstance(it) }) { "no $kind" } }
assertIs<BlockToken.QuoteMark>((quote.formattedText!![0].format as Format.Markup).token)
val bold = headingAndParagraph.formattedText!!.mapNotNull { it.format as? Format.Bold } + quote.formattedText!!.mapNotNull { it.format as? Format.Bold }
assertEquals(listOf(true, false), bold.map { it.singleStar })
assertEquals(listOf("text ", "", "*not", "", "*\nmore"), escapes.formattedText!!.map(::display))
assertNull(escapes.markdownBlocks)
}
@Test
fun testDecodeBlocks() {
val heading = assertIs<MarkdownBlock.Heading>(headingAndParagraph.markdownBlocks!![0])
assertEquals(1, heading.level)
assertEquals(1 to 3, heading.content.pair)
assertEquals(4 to 9, assertIs<MarkdownBlock.Paragraph>(headingAndParagraph.markdownBlocks!![1]).content.pair)
val list = assertIs<MarkdownBlock.ItemList>(nestedList.markdownBlocks!!.single())
assertEquals(listOf(null, "2."), list.items.map { it.number })
assertEquals(7 to 8, list.items[0].items.single().content.pair)
assertEquals(1 to 10, assertIs<MarkdownBlock.Quote>(quote.markdownBlocks!!.single()).content.pair)
val code = assertIs<MarkdownBlock.CodeBlock>(codeBlockAndRule.markdownBlocks!![0])
assertEquals("bash", code.language)
assertEquals(2 to 6, code.content.pair)
assertEquals(9 to 10, assertIs<MarkdownBlock.Rule>(codeBlockAndRule.markdownBlocks!![1]).mark.pair)
val table = assertIs<MarkdownBlock.Table>(tableAligned.markdownBlocks!!.single())
assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), table.columns)
assertEquals(listOf(1 to 2, 3 to 4, 5 to 6, 7 to 8), table.header.map { it.pair })
assertEquals(listOf(listOf(13 to 14, 15 to 16, 17 to 18)), table.rows.map { row -> row.map { it.pair } })
}
@Test
fun testBlockTextRanges() {
assertEquals(listOf(1 to 3, 4 to 9), texts(headingAndParagraph).map { it.first })
assertEquals(listOf(1 to 5, 7 to 8, 10 to 11), texts(nestedList).map { it.first })
assertEquals(listOf(1 to 10), texts(quote).map { it.first })
assertEquals(listOf(2 to 6), texts(codeBlockAndRule).map { it.first })
assertEquals(
listOf(1 to 2 to false, 3 to 4 to true, 5 to 6 to true, 7 to 8 to true, 13 to 14 to false, 15 to 16 to true, 17 to 18 to true),
texts(tableAligned)
)
}
@Test
fun testListEntryLevels() {
val list = assertIs<MarkdownBlock.ItemList>(nestedList.markdownBlocks!!.single())
assertEquals(listOf(1 to 0, 7 to 1, 10 to 0), listEntries(list.items, 0).map { (entry, level) -> entry.content.from to level })
}
@Test
fun testHeadingSegments() {
assertEquals(listOf(1, 2), headingSegments(9, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index })
assertEquals(listOf(4), headingSegments(6, gaps.markdownBlocks!!).withIndex().filter { it.value }.map { it.index })
assertEquals(listOf(1), headingSegments(2, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index })
}
@Test
fun testTokenTexts() {
val tokens = listOf(
BlockToken.SoftBreak(), BlockToken.LineBreak(), BlockToken.HardBreak(), BlockToken.ParagraphBreak(3), BlockToken.Indent(),
BlockToken.HeadingMark(2), BlockToken.ListMark("1."), BlockToken.ListMark(null), BlockToken.QuoteMark(), BlockToken.FenceMark(null),
BlockToken.RowMark(), BlockToken.CellMark(), BlockToken.DelimiterRow(listOf(TableAlign.None)), BlockToken.RuleMark()
)
assertEquals(listOf(" ", "\n", "\n", "\n\n\n", "", "", "", "", "", "", "", "", "", ""), tokens.map { it.drawnText })
assertEquals(listOf(" ", "\n", " ", "\n", "", "", "1. ", "• ", "", "", "", " ", "", ""), tokens.map { it.previewText })
assertEquals("", display(FormattedText("\\", Format.Escape())))
assertEquals("Title x\nPara a b c", preview(headingAndParagraph))
assertEquals("• x y\n• z\n2. w", preview(nestedList))
}
@Test
fun testItemDisplay() {
val item = ChatItem.getSampleData(text = "> q1\n> q2\n>\n> **q3**").copy(formattedText = quote.formattedText, markdownBlocks = quote.markdownBlocks)
val display = itemDisplay(item, SimplexLinkMode.DESCRIPTION)!!
assertEquals("q1 q2\n\nq3", display.parts.joinToString("") { it.first })
assertEquals(listOf(0), display.bases)
val table = item.copy(formattedText = tableAligned.formattedText, markdownBlocks = tableAligned.markdownBlocks)
val tableDisplay = itemDisplay(table, SimplexLinkMode.DESCRIPTION)!!
assertEquals("a\tb\tc\td\nxxx\tyyy\tzzz", tableDisplay.parts.joinToString("") { it.first })
assertEquals(listOf(0, 2, 4, 6, 8, 12, 16), tableDisplay.bases)
val plain = item.copy(formattedText = escapes.formattedText, markdownBlocks = null)
val plainDisplay = itemDisplay(plain, SimplexLinkMode.DESCRIPTION)!!
assertEquals(listOf("text " to "text ", "" to "\\", "*not" to "*not", "" to "\\", "*\nmore" to "*\nmore"), plainDisplay.parts)
assertEquals(listOf(0), plainDisplay.bases)
}
private val SegmentRange.pair: Pair<Int, Int> get() = from to to
private fun parsed(s: String): ParsedFormattedText = json.decodeFromString(ParsedFormattedText.serializer(), s)
private fun display(ft: FormattedText): String = segmentDisplayText(ft, null, SimplexLinkMode.DESCRIPTION)
private fun texts(p: ParsedFormattedText): List<Pair<Pair<Int, Int>, Boolean>> =
blockTextRanges(p.markdownBlocks!!).map { it.range.pair to it.inRow }
private fun preview(p: ParsedFormattedText): String =
p.formattedText!!.joinToString("") { ft -> (ft.format as? Format.Markup)?.token?.previewText ?: ft.text }
}
@@ -313,7 +313,9 @@ actual object CallSoundsPlayer: CallSoundsPlayerInterface {
override fun startConnectingCallSound(scope: CoroutineScope) {
// Taken from https://github.com/TelegramOrg/Telegram-Android
// https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE
start("/media/connecting_call.mp3", 3000, scope)
// the delay is the length of the track exactly, so it repeats with no delay, which already happens on ios and android
// Android (isLooping) and iOS (which sleeps for the track duration)
start("/media/connecting_call.mp3", 3536, scope)
}
override fun startInCallSound(scope: CoroutineScope) {
@@ -8,6 +8,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
@@ -55,13 +56,24 @@ actual fun ActiveCallView() {
CallSoundsPlayer.stop()
}
is WCallResponse.Ice -> withBGApi {
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates)
chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates)
}
is WCallResponse.Connection ->
try {
// the call is restored and NOT sent to the core
// Makes sure the core DOES NOT record the duration wrong if a reconnect happens
if (r.state.connectionState == "reconnecting") {
if (call.callState != CallState.Reconnecting) {
CallSoundsPlayer.startConnectingCallSound(scope)
}
chatModel.activeCall.value = call.copy(callState = CallState.Reconnecting)
} else try {
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
if (callStatus == WebRTCCallStatus.Connected) {
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
if (call.callState == CallState.Reconnecting) {
CallSoundsPlayer.stop()
}
// connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = call.connectedAt ?: Clock.System.now())
}
withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) }
} catch (e: Throwable) {
@@ -194,21 +206,20 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
}
}
LaunchedEffect(Unit) {
snapshotFlow { callCommand.firstOrNull() }
.distinctUntilChanged()
.filterNotNull()
.collect {
while (connections.isEmpty()) {
delay(100)
}
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(cmd)
}
while (isActive) {
// waiting for the first command would miss a command equal to the previous one
snapshotFlow { callCommand.isNotEmpty() }.first { it }
while (connections.isEmpty()) {
delay(100)
}
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(cmd)
}
}
}
}
}