amethyst/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt

308 wiersze
12 KiB
Kotlin
Czysty Zwykły widok Historia

2023-01-11 18:31:20 +00:00
package com.vitorpamplona.amethyst.ui.components
2023-01-13 17:30:13 +00:00
import android.util.Patterns
2023-02-06 19:34:21 +00:00
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
2023-02-20 23:09:57 +00:00
import androidx.compose.foundation.border
2023-02-27 16:28:54 +00:00
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
2023-02-20 23:09:57 +00:00
import androidx.compose.foundation.shape.RoundedCornerShape
2023-03-15 21:02:49 +00:00
import androidx.compose.foundation.text.ClickableText
2023-02-27 16:28:54 +00:00
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
2023-01-11 18:31:20 +00:00
import androidx.compose.ui.Modifier
2023-02-20 23:09:57 +00:00
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
2023-03-15 21:02:49 +00:00
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
2023-02-20 23:09:57 +00:00
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
2023-01-11 18:31:20 +00:00
import com.google.accompanist.flowlayout.FlowRow
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
2023-01-11 18:31:20 +00:00
import com.vitorpamplona.amethyst.model.LocalCache
2023-03-07 19:43:34 +00:00
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
2023-03-08 12:07:33 +00:00
import com.vitorpamplona.amethyst.service.nip19.Nip19
2023-02-20 23:09:57 +00:00
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
2023-01-11 18:31:20 +00:00
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
2023-01-16 17:57:23 +00:00
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
2023-03-13 17:49:06 +00:00
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$")
2023-01-11 18:31:20 +00:00
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
2023-01-11 18:31:20 +00:00
2023-03-15 21:02:49 +00:00
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_\\-]+)")
2023-01-13 17:30:13 +00:00
val urlPattern: Pattern = Patterns.WEB_URL
2023-01-11 18:31:20 +00:00
fun isValidURL(url: String?): Boolean {
2023-03-07 19:43:34 +00:00
return try {
URL(url).toURI()
true
} catch (e: MalformedURLException) {
false
} catch (e: URISyntaxException) {
false
}
2023-01-11 18:31:20 +00:00
}
@Composable
fun RichTextViewer(
2023-03-07 19:43:34 +00:00
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
2023-03-07 19:43:34 +00:00
val myMarkDownStyle = RichTextStyle().resolveDefaults().copy(
codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor))
),
stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
)
)
)
2023-03-07 19:43:34 +00:00
Column(modifier = modifier.animateContentSize()) {
if (content.startsWith("# ") ||
content.contains("##") ||
content.contains("**") ||
content.contains("__") ||
content.contains("```")
) {
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = content,
markdownParseOptions = MarkdownParseOptions.Default
)
}
2023-03-07 19:43:34 +00:00
} else {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ')
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
2023-03-15 21:02:49 +00:00
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
2023-03-07 19:43:34 +00:00
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
if (isValidURL(word)) {
ClickableUrl("$word ", word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
2023-03-15 21:02:49 +00:00
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
2023-03-07 19:43:34 +00:00
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
}
}
}
}
2023-01-11 18:31:20 +00:00
}
}
private fun isArabic(text: String): Boolean {
2023-03-07 19:43:34 +00:00
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
fun isBechLink(word: String): Boolean {
val cleaned = word.removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it, true) }
}
@Composable
fun BechLink(word: String, navController: NavController) {
2023-03-07 19:43:34 +00:00
val uri = if (word.startsWith("nostr", true)) {
word
} else if (word.startsWith("@")) {
word.replaceFirst("@", "nostr:")
} else {
"nostr:$word"
}
2023-03-07 19:43:34 +00:00
val nip19Route = try {
2023-03-08 12:07:33 +00:00
Nip19.uriToRoute(uri)
2023-03-07 19:43:34 +00:00
} catch (e: Exception) {
null
}
2023-03-07 19:43:34 +00:00
if (nip19Route == null) {
Text(text = "$word ")
} else {
ClickableRoute(nip19Route, navController)
}
}
2023-03-15 21:02:49 +00:00
@Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val tag = try {
hashtagMatcher.find()
hashtagMatcher.group(1)
} catch (e: Exception) {
println("Couldn't link hashtag $word")
null
}
if (tag != null) {
ClickableText(
text = AnnotatedString("#$tag "),
onClick = { navController.navigate("Hashtag/$tag") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {
Text(text = "$word ")
}
}
2023-01-11 18:31:20 +00:00
@Composable
2023-02-27 22:14:15 +00:00
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
2023-03-07 19:43:34 +00:00
val matcher = tagIndex.matcher(word)
2023-01-11 18:31:20 +00:00
2023-03-07 19:43:34 +00:00
val index = try {
matcher.find()
2023-03-13 17:47:44 +00:00
matcher.group(1)?.toInt()
2023-03-07 19:43:34 +00:00
} catch (e: Exception) {
println("Couldn't link tag $word")
null
}
2023-01-11 18:31:20 +00:00
2023-03-07 19:43:34 +00:00
if (index == null) {
return Text(text = "$word ")
}
2023-01-11 18:31:20 +00:00
2023-03-07 19:43:34 +00:00
if (index >= 0 && index < tags.size) {
if (tags[index][0] == "p") {
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
if (baseUser != null) {
val userState = baseUser.live().metadata.observeAsState()
val user = userState.value?.user
if (user != null) {
ClickableUserTag(user, navController)
} else {
Text(text = "$word ")
}
} else {
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
}
} else if (tags[index][0] == "e") {
val note = LocalCache.checkGetOrCreateNote(tags[index][1])
if (note != null) {
if (canPreview) {
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
} else {
ClickableNoteTag(note, navController)
}
} else {
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
}
2023-02-27 22:14:15 +00:00
} else {
2023-03-07 19:43:34 +00:00
Text(text = "$word ")
2023-02-27 22:14:15 +00:00
}
2023-03-07 19:43:34 +00:00
}
2023-01-16 15:51:10 +00:00
}