mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 19:42:13 +00:00
Merge branch 'master' into nd/fix-profile-picker-failed-change
This commit is contained in:
commit
f03ea4c4b2
143 files changed
+9738
-10981
No files matched your search
+24
-15
@@ -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()
|
||||
|
||||
+94
-4
@@ -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,
|
||||
)
|
||||
|
||||
+3
-3
@@ -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 -> {
|
||||
|
||||
+6
-1
@@ -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 {
|
||||
|
||||
+5
-3
@@ -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?,
|
||||
|
||||
+4
-2
@@ -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 {
|
||||
|
||||
+7
-5
@@ -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)) {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+58
-39
@@ -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).
|
||||
|
||||
+5
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+248
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
-27
@@ -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)
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+6
-3
@@ -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
|
||||
+133
@@ -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 }
|
||||
}
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
+27
-16
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user