android: show markdown in messages (#361)

* android: show markdown in messages

* empty line
This commit is contained in:
Evgeny Poberezkin
2022-02-23 12:30:48 +00:00
committed by GitHub
parent 8f21453e82
commit 470b18786e
9 changed files with 143 additions and 32 deletions

View File

@@ -13,7 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.lifecycle.AndroidViewModel
import androidx.navigation.*
import androidx.navigation.compose.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.*
import chat.simplex.app.views.chat.ChatInfoView
@@ -26,6 +26,7 @@ import chat.simplex.app.views.usersettings.UserProfileView
import com.google.accompanist.insets.ExperimentalAnimatedInsets
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.serialization.decodeFromString
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@@ -36,6 +37,7 @@ class MainActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// testJson()
connectIfOpenedViaUri(intent, vm.chatModel)
setContent {
SimpleXTheme {
@@ -158,3 +160,11 @@ fun connectIfOpenedViaUri(intent: Intent?, chatModel: ChatModel) {
}
}
}
fun testJson() {
val str = """
{}
""".trimIndent()
println(json.decodeFromString<ChatItem>(str))
}

View File

@@ -1,9 +1,14 @@
package chat.simplex.app.model
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.app.SimplexApp
import chat.simplex.app.ui.theme.HighOrLowlight
import kotlinx.datetime.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -568,7 +573,14 @@ sealed class MsgContent {
}
@Serializable
class FormattedText(val text: String, val format: Format? = null)
class FormattedText(val text: String, val format: Format? = null) {
val link: String? = when (format) {
is Format.Uri -> text
is Format.Email -> "mailto:$text"
is Format.Phone -> "tel:$text"
else -> null
}
}
@Serializable
sealed class Format {
@@ -582,18 +594,46 @@ sealed class Format {
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format()
val style: SpanStyle @Composable get() = when (this) {
is Bold -> SpanStyle(fontWeight = FontWeight.Bold)
is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
is Underline -> SpanStyle(textDecoration = TextDecoration.Underline)
is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
is Secret -> SpanStyle(color = HighOrLowlight, background = HighOrLowlight)
is Colored -> SpanStyle(color = this.formatColor.uiColor)
is Uri -> linkStyle
is Email -> linkStyle
is Phone -> linkStyle
}
companion object {
val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
}
}
@Serializable
enum class FormatColor(val color: String) {
Red("red"),
Green("green"),
Blue("blue"),
Yellow("yellow"),
Cyan("cyan"),
Magenta("magenta"),
Black("black"),
White("white")
red("red"),
green("green"),
blue("blue"),
yellow("yellow"),
cyan("cyan"),
magenta("magenta"),
black("black"),
white("white");
val uiColor: Color @Composable get() = when (this) {
red -> Color.Red
green -> Color.Green
blue -> Color.Blue
yellow -> Color.Yellow
cyan -> Color.Cyan
magenta -> Color.Magenta
black -> MaterialTheme.colors.onBackground
white -> MaterialTheme.colors.onBackground
}
}
@Serializable

View File

@@ -11,6 +11,8 @@ import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -25,8 +27,8 @@ import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.insets.*
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import java.util.*
@ExperimentalTextApi
@ExperimentalAnimatedInsets
@DelicateCoroutinesApi
@Composable
@@ -34,7 +36,6 @@ fun ChatView(chatModel: ChatModel, nav: NavController) {
if (chatModel.chatId.value != null && chatModel.chats.count() > 0) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
if (chat != null) {
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
delay(1000L)
@@ -70,6 +71,7 @@ fun ChatView(chatModel: ChatModel, nav: NavController) {
}
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
@@ -134,15 +136,17 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
}
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
fun ChatItemsList(chatItems: List<ChatItem>) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
LazyColumn(state = listState) {
items(chatItems) { cItem ->
ChatItemView(cItem)
ChatItemView(cItem, uriHandler)
}
val len = chatItems.count()
if (len > 1) {
@@ -153,6 +157,7 @@ fun ChatItemsList(chatItems: List<ChatItem>) {
}
}
@ExperimentalTextApi
@ExperimentalAnimatedInsets
@Preview(showBackground = true)
@Preview(

View File

@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.CIDirection
@@ -11,8 +13,9 @@ import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
@ExperimentalTextApi
@Composable
fun ChatItemView(chatItem: ChatItem) {
fun ChatItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
@@ -26,10 +29,11 @@ fun ChatItemView(chatItem: ChatItem) {
),
contentAlignment = alignment,
) {
TextItemView(chatItem)
TextItemView(chatItem, uriHandler)
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewChatItemView() {

View File

@@ -2,11 +2,14 @@ package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.CIDirection
@@ -18,8 +21,9 @@ import kotlinx.datetime.Clock
val SentColorLight = Color(0x1E45B8FF)
val ReceivedColorLight = Color(0x1EF1F0F5)
@ExperimentalTextApi
@Composable
fun TextItemView(chatItem: ChatItem) {
fun TextItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
Surface(
shape = RoundedCornerShape(18.dp),
@@ -29,13 +33,55 @@ fun TextItemView(chatItem: ChatItem) {
modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp)
) {
Column {
Text(text = chatItem.content.text)
MarkdownText(chatItem, uriHandler = uriHandler)
CIMetaView(chatItem)
}
}
}
}
@ExperimentalTextApi
@Composable
fun MarkdownText (
chatItem: ChatItem,
style: TextStyle = MaterialTheme.typography.body1,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
uriHandler: UriHandler? = null,
modifier: Modifier = Modifier
) {
if (chatItem.formattedText == null) {
Text(chatItem.content.text, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
} else {
val annotatedText = buildAnnotatedString {
for (ft in chatItem.formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link
if (link != null) {
withAnnotation(tag = "URL", annotation = link) {
withStyle(ft.format.style) { append(ft.text) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }
}
}
}
}
if (uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
}
)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewSnd() {
@@ -48,6 +94,7 @@ fun PreviewTextItemViewSnd() {
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewRcv() {
@@ -60,6 +107,7 @@ fun PreviewTextItemViewRcv() {
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewLong() {

View File

@@ -7,6 +7,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -15,10 +16,12 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
import kotlinx.datetime.Clock
@ExperimentalTextApi
@Composable
fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) {
Surface(
@@ -48,8 +51,8 @@ fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) {
fontWeight = FontWeight.Bold
)
if (chat.chatItems.count() > 0) {
Text(
chat.chatItems.last().content.text,
MarkdownText(
chat.chatItems.last(),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
@@ -83,6 +86,7 @@ fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) {
}
}
@ExperimentalTextApi
@Preview
@Composable
fun ChatPreviewViewExample() {

View File

@@ -671,7 +671,7 @@ enum Format: Decodable {
case strikeThrough
case snippet
case secret
case colored(formatColor: FormatColor)
case colored(color: FormatColor)
case uri
case email
case phone

View File

@@ -32,7 +32,7 @@ data Format
| StrikeThrough
| Snippet
| Secret
| Colored {formatColor :: FormatColor}
| Colored {color :: FormatColor}
| Uri
| Email
| Phone
@@ -178,13 +178,13 @@ markdownP = mconcat <$> A.many' fragmentP
ss = b <> s <> a
coloredP :: Parser Markdown
coloredP = do
color <- A.takeWhile (\c -> c /= ' ' && c /= colorMD)
case M.lookup color colors of
cStr <- A.takeWhile (\c -> c /= ' ' && c /= colorMD)
case M.lookup cStr colors of
Just c ->
let f = Colored c
in (A.char ' ' *> formattedP colorMD (color `T.snoc` ' ') f)
<|> noFormat (colorMD `T.cons` color)
_ -> noFormat (colorMD `T.cons` color)
in (A.char ' ' *> formattedP colorMD (cStr `T.snoc` ' ') f)
<|> noFormat (colorMD `T.cons` cStr)
_ -> noFormat (colorMD `T.cons` cStr)
wordsP :: Parser Markdown
wordsP = do
word <- wordMD <$> A.takeTill (== ' ')

View File

@@ -83,8 +83,8 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem
deriving (Show, Generic)
instance ToJSON (ChatItem c d) where
toJSON = J.genericToJSON J.defaultOptions
toEncoding = J.genericToEncoding J.defaultOptions
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data CIDirection (c :: ChatType) (d :: MsgDirection) where
CIDirectSnd :: CIDirection 'CTDirect 'MDSnd