Improving performance of the RichText Viewer

This commit is contained in:
Vitor Pamplona
2023-05-07 16:12:28 -04:00
parent 936f750826
commit 13de5973ca
7 changed files with 330 additions and 193 deletions
@@ -42,11 +42,13 @@ fun ExpandableRichTextViewer(
) {
var showFullText by remember { mutableStateOf(false) }
// Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val whereToCut = remember {
// Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val whereToCut = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
}
val text = if (showFullText) {
content
@@ -75,6 +75,14 @@ fun isValidURL(url: String?): Boolean {
val richTextDefaults = RichTextStyle().resolveDefaults()
fun isMarkdown(content: String): Boolean {
return content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("```")
}
@Composable
fun RichTextViewer(
content: String,
@@ -85,58 +93,39 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController
) {
val isMarkdown = remember { isMarkdown(content) }
Column(modifier = modifier) {
if (content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("```")
) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.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 = richTextDefaults.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)
)
)
)
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = markdownWithSpecialContent,
markdownParseOptions = MarkdownParseOptions.Default
)
}
if (isMarkdown) {
RenderContentAsMarkdown(content, backgroundColor)
} else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, navController)
}
}
}
class RichTextViewerState(
val content: String,
val urlSet: LinkedHashSet<String>,
val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent>
)
@Composable
private fun RenderRegular(
content: String,
tags: List<List<String>>?,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
var processedState by remember {
mutableStateOf<RichTextViewerState?>(null)
}
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl ->
@@ -151,67 +140,71 @@ fun RichTextViewer(
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
// 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.trim().split(' ').reversed() else paragraph.trim().split(' ')
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = imagesForPager[word]
if (img != null) {
ZoomableContentView(img, imageList)
} else if (urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
}
}
// FlowRow doesn't work well with paragraphs. So we need to split them
processedState?.let { state ->
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) {
paragraph.trim().split(' ')
.reversed()
} else {
paragraph.trim().split(' ')
}
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = state.imagesForPager[word]
if (img != null) {
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
@@ -219,69 +212,74 @@ fun RichTextViewer(
)
}
} else {
if (urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
}
@@ -291,21 +289,137 @@ fun RichTextViewer(
}
@Composable
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
if (parsedNip19.type == Nip19.Type.USER) {
val userHex = parsedNip19.hex
val userBase = LocalCache.getOrCreateUser(userHex)
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.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 = richTextDefaults.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)
)
)
)
val userState by userBase.live().metadata.observeAsState()
val displayName = userState?.user?.bestDisplayName()
if (displayName !== null) {
return displayName
var markdownWithSpecialContent by remember { mutableStateOf<String?>(null) }
var nip19References by remember { mutableStateOf<List<Nip19.Return>>(emptyList()) }
var refresh by remember { mutableStateOf(0) }
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
nip19References = returnNIP19References(content)
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
}
}
LaunchedEffect(key1 = refresh) {
withContext(Dispatchers.IO) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
}
}
nip19References.forEach {
var baseUser by remember { mutableStateOf<User?>(null) }
var baseNote by remember { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = it.hex) {
withContext(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNote = note
}
}
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
baseUser = user
}
}
}
}
baseNote?.let {
val noteState by it.live().metadata.observeAsState()
if (noteState?.note?.event != null) {
refresh++
}
}
baseUser?.let {
val userState by it.live().metadata.observeAsState()
if (userState?.user?.info != null) {
refresh++
}
}
}
markdownWithSpecialContent?.let {
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default
)
}
}
return null
}
@Composable
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
if (nip19.type == Nip19.Type.USER) {
return LocalCache.users[nip19.hex]?.bestDisplayName()
} else if (nip19.type == Nip19.Type.NOTE) {
return LocalCache.notes[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.ADDRESS) {
return LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.EVENT) {
return LocalCache.notes[nip19.hex]?.idDisplayNote() ?: LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else {
return null
}
}
private fun returnNIP19References(content: String): List<Nip19.Return> {
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
parsedNip19?.let {
listOfReferences.add(it)
}
}
}
}
return listOfReferences
}
private fun returnMarkdownWithSpecialContent(content: String): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
@@ -319,7 +433,7 @@ private fun returnMarkdownWithSpecialContent(content: String): String {
} else if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val displayName = getDisplayNameFromUserNip19(parsedNip19)
val displayName = getDisplayNameFromNip19(parsedNip19)
if (displayName != null) {
"[@$displayName](nostr://$word) "
} else {
@@ -209,7 +209,9 @@ private fun LocalImageView(
mutableStateOf<AsyncImagePainter.State?>(null)
}
val ratio = aspectRatio(content.dim)
val ratio = remember {
aspectRatio(content.dim)
}
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also {
@@ -257,6 +259,7 @@ private fun UrlImageView(
content: ZoomableUrlImage,
mainImageModifier: Modifier
) {
println("UrlImageView")
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -269,7 +272,9 @@ private fun UrlImageView(
mutableStateOf<Boolean?>(null)
}
val ratio = aspectRatio(content.dim)
val ratio = remember {
aspectRatio(content.dim)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
@@ -38,7 +38,6 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
val noteState by messageSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -50,10 +49,14 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
LaunchedEffect(key1 = messageSetCard.createdAt()) {
scope.launch(Dispatchers.IO) {
isNew =
val newIsNew =
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt())
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
@@ -68,9 +68,13 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
LaunchedEffect(key1 = multiSetCard.createdAt()) {
scope.launch(Dispatchers.IO) {
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
val newIsNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt)
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
@@ -249,7 +249,11 @@ fun NoteComposeInner(
val createdAt = note.createdAt()
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt)
isNew = createdAt > lastTime
val newIsNew = createdAt > lastTime
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
}
@@ -420,7 +424,8 @@ private fun RenderTextEvent(
) {
val noteEvent = note.event ?: return
val eventContent = accountViewModel.decrypt(note)
val eventContent = remember { accountViewModel.decrypt(note) }
val modifier = remember { Modifier.fillMaxWidth() }
if (eventContent != null) {
if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
@@ -434,7 +439,7 @@ private fun RenderTextEvent(
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
modifier = Modifier.fillMaxWidth(),
modifier = modifier,
tags = noteEvent.tags(),
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
@@ -548,10 +553,10 @@ private fun RenderPrivateMessage(
navController: NavController
) {
val noteEvent = note.event as? PrivateDmEvent ?: return
val withMe = noteEvent.with(accountViewModel.userProfile().pubkeyHex)
val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
if (withMe) {
val eventContent = accountViewModel.decrypt(note)
val eventContent = remember { accountViewModel.decrypt(note) }
if (eventContent != null) {
if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
@@ -908,7 +913,7 @@ fun TimeAgo(time: Long) {
@Composable
private fun DrawAuthorImages(baseNote: Note, loggedIn: User, navController: NavController) {
val baseChannel = baseNote.channel()
val baseChannel = remember { baseNote.channel() }
Column(Modifier.width(55.dp)) {
// Draws the boosted picture outside the boosted card.
@@ -1002,7 +1007,13 @@ fun DisplayHighlight(
accountViewModel: AccountViewModel,
navController: NavController
) {
val quote = highlight.split("\n").map { "> *${it.removeSuffix(" ")}*" }.joinToString("\n")
val quote =
remember {
highlight
.split("\n")
.map { "> *${it.removeSuffix(" ")}*" }
.joinToString("\n")
}
TranslatableRichTextViewer(
quote,
@@ -1205,9 +1216,9 @@ fun BadgeDisplay(baseNote: Note) {
val background = MaterialTheme.colors.background
val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
val image = badgeData.image()
val name = badgeData.name()
val description = badgeData.description()
val image = remember { badgeData.image() }
val name = remember { badgeData.name() }
val description = remember { badgeData.description() }
var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) }
var imageResult by remember { mutableStateOf<AsyncImagePainter.State.Success?>(null) }
@@ -73,8 +73,6 @@ fun TranslatableRichTextViewer(
showOriginal = preference == task.result.sourceLang
}
translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
}
}
}