From 470b18786ed467851ff4929621deface4f2d154b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 23 Feb 2022 12:30:48 +0000 Subject: [PATCH] android: show markdown in messages (#361) * android: show markdown in messages * empty line --- .../java/chat/simplex/app/MainActivity.kt | 12 +++- .../java/chat/simplex/app/model/ChatModel.kt | 62 +++++++++++++++---- .../chat/simplex/app/views/chat/ChatView.kt | 11 +++- .../app/views/chat/item/ChatItemView.kt | 8 ++- .../app/views/chat/item/TextItemView.kt | 56 +++++++++++++++-- .../app/views/chatlist/ChatPreviewView.kt | 8 ++- apps/ios/Shared/Model/ChatModel.swift | 2 +- src/Simplex/Chat/Markdown.hs | 12 ++-- src/Simplex/Chat/Messages.hs | 4 +- 9 files changed, 143 insertions(+), 32 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 337d8237e1..cd46e9d658 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -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(str)) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 6adb2731ba..e9a412abcd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -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 diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index f5369afd12..5b6e3044f5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -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) { 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) { } } +@ExperimentalTextApi @ExperimentalAnimatedInsets @Preview(showBackground = true) @Preview( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 60ec4ee67e..8a252f714b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -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() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index fdb2d5d80e..1bdd2234da 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -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() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 7097d40833..9c36141c73 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -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() { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index bff0c8a669..b5847845de 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -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 diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 544211f289..43484d73f2 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -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 (== ' ') diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index ae93e7ffc7..febd4af663 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -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