From 00a9c4991504c2ad7164cb5bdd9d5f9b7c75eb14 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 8 Apr 2024 18:53:55 -0400 Subject: [PATCH] - Migrates to the new Markdown Parser. - Adds Note previews on Markdown - Adds Custom hashtag icons to markdown. - Adds URL preview boxes to markdown - Performance improvements. --- .../ui/components/ExpandableRichTextViewer.kt | 17 +- .../amethyst/ui/components/MarkdownParser.kt | 209 ------------ .../amethyst/ui/components/RichTextViewer.kt | 223 +------------ .../markdown/MarkdownMediaRenderer.kt | 307 ++++++++++++++++++ .../markdown/RenderContentAsMarkdown.kt | 91 ++++++ .../ui/screen/loggedIn/AccountBackupDialog.kt | 35 +- .../ui/screen/loggedIn/AccountViewModel.kt | 22 -- .../ui/screen/loggedIn/ConnectOrbotDialog.kt | 21 +- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 9 + .../vitorpamplona/amethyst/ui/theme/Type.kt | 2 +- gradle/libs.versions.toml | 2 +- 11 files changed, 465 insertions(+), 473 deletions(-) delete mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index f8a8f2c80..c8285ada9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -68,15 +68,16 @@ fun ExpandableRichTextViewer( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - var showFullText by remember { - val cached = ShowFullTextCache.cache[id] - if (cached == null) { - ShowFullTextCache.cache.put(id, false) - mutableStateOf(false) - } else { - mutableStateOf(cached) + var showFullText by + remember { + val cached = ShowFullTextCache.cache[id] + if (cached == null) { + ShowFullTextCache.cache.put(id, false) + mutableStateOf(false) + } else { + mutableStateOf(cached) + } } - } val whereToCut = remember(content) { ExpandableTextCutOffCalculator.indexToCutOff(content) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt deleted file mode 100644 index 549e6f8a9..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.components - -import android.util.Log -import android.util.Patterns -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.quartz.encoders.ATag -import com.vitorpamplona.quartz.encoders.Nip19Bech32 -import com.vitorpamplona.quartz.events.ImmutableListOfLists -import kotlinx.coroutines.CancellationException - -class MarkdownParser { - private fun getDisplayNameAndNIP19FromTag( - tag: String, - tags: ImmutableListOfLists, - ): Pair? { - val matcher = RichTextParser.tagIndex.matcher(tag) - val (index, suffix) = - try { - matcher.find() - Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("Tag Parser", "Couldn't link tag $tag", e) - Pair(null, null) - } - - if (index != null && index >= 0 && index < tags.lists.size) { - val tag = tags.lists[index] - - if (tag.size > 1) { - if (tag[0] == "p") { - LocalCache.checkGetOrCreateUser(tag[1])?.let { - return Pair(it.toBestDisplayName(), it.pubkeyNpub()) - } - } else if (tag[0] == "e" || tag[0] == "a") { - LocalCache.checkGetOrCreateNote(tag[1])?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } - } - } - - return null - } - - private suspend fun getDisplayNameFromNip19(nip19: Nip19Bech32.Entity): Pair? { - return when (nip19) { - is Nip19Bech32.NSec -> null - is Nip19Bech32.NPub -> { - LocalCache.getUserIfExists(nip19.hex)?.let { - return Pair(it.toBestDisplayName(), it.pubkeyNpub()) - } - } - is Nip19Bech32.NProfile -> { - LocalCache.getUserIfExists(nip19.hex)?.let { - return Pair(it.toBestDisplayName(), it.pubkeyNpub()) - } - } - is Nip19Bech32.Note -> { - LocalCache.getNoteIfExists(nip19.hex)?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } - is Nip19Bech32.NEvent -> { - LocalCache.getNoteIfExists(nip19.hex)?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } - is Nip19Bech32.NEmbed -> { - if (LocalCache.getNoteIfExists(nip19.event.id) == null) { - LocalCache.verifyAndConsume(nip19.event, null) - } - - LocalCache.getNoteIfExists(nip19.event.id)?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } - is Nip19Bech32.NRelay -> null - is Nip19Bech32.NAddress -> { - LocalCache.getAddressableNoteIfExists(nip19.atag)?.let { - return Pair(it.idDisplayNote(), it.toNEvent()) - } - } - else -> null - } - } - - fun returnNIP19References( - content: String, - tags: ImmutableListOfLists?, - ): List { - checkNotInMainThread() - - val listOfReferences = mutableListOf() - content.split('\n').forEach { paragraph -> - paragraph.split(' ').forEach { word: String -> - if (RichTextParser.startsWithNIP19Scheme(word)) { - val parsedNip19 = Nip19Bech32.uriToRoute(word) - parsedNip19?.let { listOfReferences.add(it.entity) } - } - } - } - - tags?.lists?.forEach { - if (it[0] == "p" && it.size > 1) { - listOfReferences.add(Nip19Bech32.NProfile(it[1], listOfNotNull(it.getOrNull(2)))) - } else if (it[0] == "e" && it.size > 1) { - listOfReferences.add(Nip19Bech32.NEvent(it[1], listOfNotNull(it.getOrNull(2)), null, null)) - } else if (it[0] == "a" && it.size > 1) { - ATag.parseAtag(it[1], it.getOrNull(2))?.let { atag -> - listOfReferences.add(Nip19Bech32.NAddress(it[1], listOfNotNull(atag.relay), atag.pubKeyHex, atag.kind)) - } - } - } - - return listOfReferences - } - - suspend fun returnMarkdownWithSpecialContent( - content: String, - tags: ImmutableListOfLists?, - ): String { - var returnContent = "" - content.split('\n').forEach { paragraph -> - paragraph.split(' ').forEach { word: String -> - if (RichTextParser.isValidURL(word)) { - if (RichTextParser.isImageUrl(word)) { - returnContent += "![]($word) " - } else { - returnContent += "[$word]($word) " - } - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - returnContent += "[$word](mailto:$word) " - } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { - returnContent += "[$word](tel:$word) " - } else if (RichTextParser.startsWithNIP19Scheme(word)) { - val parsedNip19 = Nip19Bech32.uriToRoute(word) - returnContent += - if (parsedNip19?.entity !== null) { - val pair = getDisplayNameFromNip19(parsedNip19.entity) - if (pair != null) { - val (displayName, nip19) = pair - "[$displayName](nostr:$nip19) " - } else { - "$word " - } - } else { - "$word " - } - } else if (word.startsWith("#")) { - if (RichTextParser.tagIndex.matcher(word).matches() && tags != null) { - val pair = getDisplayNameAndNIP19FromTag(word, tags) - if (pair != null) { - returnContent += "[${pair.first}](nostr:${pair.second}) " - } else { - returnContent += "$word " - } - } else if (RichTextParser.hashTagsPattern.matcher(word).matches()) { - val hashtagMatcher = RichTextParser.hashTagsPattern.matcher(word) - - val (myTag, mySuffix) = - try { - hashtagMatcher.find() - Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) - Pair(null, null) - } - - if (myTag != null) { - returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix " - } else { - returnContent += "$word " - } - } else { - returnContent += "$word " - } - } else { - returnContent += "$word " - } - } - returnContent += "\n" - } - return returnContent - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 7c9499901..9d177f406 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -29,12 +29,12 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -51,9 +51,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle @@ -65,9 +62,6 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.lifecycle.viewmodel.compose.viewModel -import com.halilibo.richtext.markdown.Markdown -import com.halilibo.richtext.markdown.MarkdownParseOptions -import com.halilibo.richtext.ui.material3.Material3RichText import com.vitorpamplona.amethyst.commons.compose.produceCachedState import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment @@ -79,10 +73,8 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment import com.vitorpamplona.amethyst.commons.richtext.ImageSegment import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment -import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment import com.vitorpamplona.amethyst.commons.richtext.Segment @@ -93,33 +85,29 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown import com.vitorpamplona.amethyst.ui.note.LoadUser import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.Font17SP import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding -import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle +import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.innerPostModifier -import com.vitorpamplona.amethyst.ui.theme.markdownStyle -import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.crypto.KeyPair -import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists import fr.acinq.secp256k1.Hex import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch fun isMarkdown(content: String): Boolean { return content.startsWith("> ") || content.startsWith("# ") || content.contains("##") || content.contains("__") || + content.contains("**") || content.contains("```") || content.contains("](") } @@ -137,7 +125,7 @@ fun RichTextViewer( ) { Column(modifier = modifier) { if (remember(content) { isMarkdown(content) }) { - RenderContentAsMarkdown(content, tags, accountViewModel, nav) + RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) } else { RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) } @@ -346,17 +334,6 @@ fun RenderRegular( } } } - - /* - // UrlPreviews and Images have a 5dp spacing down. This also adds the space to Text. - val lastElement = state.paragraphs.lastOrNull()?.words?.lastOrNull() - if (lastElement !is ImageSegment && - lastElement !is LinkSegment && - lastElement !is InvoiceSegment && - lastElement !is CashuSegment - ) { - Spacer(modifier = StdVertSpacer) - }*/ } } @@ -462,186 +439,6 @@ fun RenderCustomEmoji( ) } -val markdownParseOptions = - MarkdownParseOptions( - autolink = true, - isImage = { url -> RichTextParser.isImageOrVideoUrl(url) }, - ) - -@Composable -private fun RenderContentAsMarkdown( - content: String, - tags: ImmutableListOfLists?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val uri = LocalUriHandler.current - val onClick = - remember { - { link: String -> - val route = uriToRoute(link) - if (route != null) { - nav(route) - } else { - runCatching { uri.openUri(link) } - } - Unit - } - } - - ProvideTextStyle(MarkdownTextStyle) { - Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) { - RefreshableContent(content, tags, accountViewModel) { - Markdown( - content = it, - markdownParseOptions = markdownParseOptions, - onLinkClicked = onClick, - onMediaCompose = { title, destination -> - ZoomableContentView( - content = - remember(destination, tags) { - RichTextParser().parseMediaUrl( - destination, - tags ?: EmptyTagList, - title.ifEmpty { null } ?: content, - ) ?: MediaUrlImage(url = destination, description = title.ifEmpty { null } ?: content) - }, - roundedCorner = true, - accountViewModel = accountViewModel, - ) - }, - ) - } - } - } -} - -@Composable -private fun RefreshableContent( - content: String, - tags: ImmutableListOfLists?, - accountViewModel: AccountViewModel, - onCompose: @Composable (String) -> Unit, -) { - var markdownWithSpecialContent by remember(content) { mutableStateOf(content) } - - ObserverAllNIP19References(content, tags, accountViewModel) { - accountViewModel.returnMarkdownWithSpecialContent(content, tags) { newMarkdownWithSpecialContent -> - if (markdownWithSpecialContent != newMarkdownWithSpecialContent) { - markdownWithSpecialContent = newMarkdownWithSpecialContent - } - } - } - - markdownWithSpecialContent?.let { onCompose(it) } -} - -@Composable -fun ObserverAllNIP19References( - content: String, - tags: ImmutableListOfLists?, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit, -) { - var nip19References by remember(content) { mutableStateOf>(emptyList()) } - - LaunchedEffect(key1 = content) { - accountViewModel.returnNIP19References(content, tags) { - nip19References = it - onRefresh() - } - } - - nip19References.forEach { ObserveNIP19(it, accountViewModel, onRefresh) } -} - -@Composable -fun ObserveNIP19( - entity: Nip19Bech32.Entity, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit, -) { - when (entity) { - is Nip19Bech32.NPub -> ObserveNIP19User(entity.hex, accountViewModel, onRefresh) - is Nip19Bech32.NProfile -> ObserveNIP19User(entity.hex, accountViewModel, onRefresh) - - is Nip19Bech32.Note -> ObserveNIP19Event(entity.hex, accountViewModel, onRefresh) - is Nip19Bech32.NEvent -> ObserveNIP19Event(entity.hex, accountViewModel, onRefresh) - is Nip19Bech32.NEmbed -> ObserveNIP19Event(entity.event.id, accountViewModel, onRefresh) - - is Nip19Bech32.NAddress -> ObserveNIP19Event(entity.atag, accountViewModel, onRefresh) - - is Nip19Bech32.NSec -> {} - is Nip19Bech32.NRelay -> {} - } -} - -@Composable -private fun ObserveNIP19Event( - hex: HexKey, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit, -) { - var baseNote by remember(hex) { mutableStateOf(accountViewModel.getNoteIfExists(hex)) } - - if (baseNote == null) { - LaunchedEffect(key1 = hex) { - accountViewModel.checkGetOrCreateNote(hex) { note -> - launch(Dispatchers.Main) { baseNote = note } - } - } - } - - baseNote?.let { note -> ObserveNote(note, onRefresh) } -} - -@Composable -fun ObserveNote( - note: Note, - onRefresh: () -> Unit, -) { - val loadedNoteId by note.live().metadata.observeAsState() - - LaunchedEffect(key1 = loadedNoteId) { - if (loadedNoteId != null) { - onRefresh() - } - } -} - -@Composable -private fun ObserveNIP19User( - hex: HexKey, - accountViewModel: AccountViewModel, - onRefresh: () -> Unit, -) { - var baseUser by remember(hex) { mutableStateOf(accountViewModel.getUserIfExists(hex)) } - - if (baseUser == null) { - LaunchedEffect(key1 = hex) { - accountViewModel.checkGetOrCreateUser(hex)?.let { user -> - launch(Dispatchers.Main) { baseUser = user } - } - } - } - - baseUser?.let { user -> ObserveUser(user, onRefresh) } -} - -@Composable -private fun ObserveUser( - user: User, - onRefresh: () -> Unit, -) { - val loadedUserMetaId by user.live().metadata.observeAsState() - - LaunchedEffect(key1 = loadedUserMetaId) { - if (loadedUserMetaId != null) { - onRefresh() - } - } -} - @Composable fun BechLink( word: String, @@ -683,7 +480,7 @@ fun BechLink( } @Composable -private fun DisplayFullNote( +fun DisplayFullNote( note: Note, extraChars: String?, quotesLeft: Int, @@ -752,13 +549,7 @@ fun HashTag( @Composable private fun InlineIcon(hashtagIcon: HashtagIcon) = - InlineTextContent( - Placeholder( - width = Font17SP, - height = Font17SP, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - ) { + InlineTextContent(inlinePlaceholder) { Icon( imageVector = hashtagIcon.icon, contentDescription = hashtagIcon.description, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt new file mode 100644 index 000000000..dc67c170b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -0,0 +1,307 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components.markdown + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.halilibo.richtext.ui.MediaRenderer +import com.halilibo.richtext.ui.string.InlineContent +import com.halilibo.richtext.ui.string.RichTextString +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.HashtagIcon +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon +import com.vitorpamplona.amethyst.ui.components.DisplayFullNote +import com.vitorpamplona.amethyst.ui.components.DisplayUser +import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink +import com.vitorpamplona.amethyst.ui.theme.Font17SP +import com.vitorpamplona.amethyst.ui.theme.Size17Modifier +import com.vitorpamplona.quartz.encoders.Nip19Bech32 +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.ImmutableListOfLists +import kotlinx.coroutines.runBlocking + +class MarkdownMediaRenderer( + val startOfText: String, + val tags: ImmutableListOfLists?, + val canPreview: Boolean, + val quotesLeft: Int, + val backgroundColor: MutableState, + val accountViewModel: AccountViewModel, + val nav: (String) -> Unit, +) : MediaRenderer { + val parser = RichTextParser() + + override fun shouldRenderLinkPreview( + title: String?, + uri: String, + ): Boolean { + return if (canPreview && uri.startsWith("http")) { + if (title.isNullOrBlank() || title == uri) { + true + } else { + false + } + } else { + false + } + } + + override fun renderImage( + title: String?, + uri: String, + richTextStringBuilder: RichTextString.Builder, + ) { + if (canPreview) { + val content = + parser.parseMediaUrl( + fullUrl = uri, + eventTags = tags ?: EmptyTagList, + description = title?.ifEmpty { null } ?: startOfText, + ) ?: MediaUrlImage(url = uri, description = title?.ifEmpty { null } ?: startOfText) + + renderInlineFullWidth(richTextStringBuilder) { + ZoomableContentView( + content = content, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + } else { + renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder) + } + } + + override fun renderLinkPreview( + title: String?, + uri: String, + richTextStringBuilder: RichTextString.Builder, + ) { + val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText) + + if (canPreview) { + if (content != null) { + renderInlineFullWidth(richTextStringBuilder) { + ZoomableContentView( + content = content, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + } else { + if (!accountViewModel.settings.showUrlPreview.value) { + renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder) + } else { + renderInlineFullWidth(richTextStringBuilder) { + LoadUrlPreview(uri, title ?: uri, accountViewModel) + } + } + } + } else { + renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder) + } + } + + override fun renderNostrUri( + uri: String, + richTextStringBuilder: RichTextString.Builder, + ) { + // This should be fast, so it is ok. + val loadedLink = + accountViewModel.bechLinkCache.cached(uri) + ?: runBlocking { + accountViewModel.bechLinkCache.update(uri) + } + + val baseNote = loadedLink?.baseNote + + if (canPreview && quotesLeft > 0 && baseNote != null) { + renderInlineFullWidth(richTextStringBuilder) { + Row { + DisplayFullNote( + note = baseNote, + extraChars = loadedLink.nip19.additionalChars?.ifBlank { null }, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else if (loadedLink?.nip19 != null) { + when (val entity = loadedLink.nip19.entity) { + is Nip19Bech32.NPub -> renderObservableUser(entity.hex, richTextStringBuilder) + is Nip19Bech32.NProfile -> renderObservableUser(entity.hex, richTextStringBuilder) + is Nip19Bech32.Note -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) + is Nip19Bech32.NEvent -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) + is Nip19Bech32.NEmbed -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) + is Nip19Bech32.NAddress -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder) + is Nip19Bech32.NRelay -> renderShortNostrURI(uri, richTextStringBuilder) + is Nip19Bech32.NSec -> renderShortNostrURI(uri, richTextStringBuilder) + else -> renderShortNostrURI(uri, richTextStringBuilder) + } + } else { + renderShortNostrURI(uri, richTextStringBuilder) + } + } + + override fun renderHashtag( + tag: String, + richTextStringBuilder: RichTextString.Builder, + ) { + val tagWithoutHash = tag.removePrefix("#") + renderAsCompleteLink(tag, "nostr:Hashtag?id=$tagWithoutHash}", richTextStringBuilder) + + val hashtagIcon: HashtagIcon? = checkForHashtagWithIcon(tagWithoutHash) + if (hashtagIcon != null) { + renderInline(richTextStringBuilder) { + Box(Size17Modifier) { + Icon( + imageVector = hashtagIcon.icon, + contentDescription = hashtagIcon.description, + tint = Color.Unspecified, + modifier = hashtagIcon.modifier, + ) + } + } + } + } + + fun renderObservableUser( + userHex: String, + richTextStringBuilder: RichTextString.Builder, + ) { + renderInline(richTextStringBuilder) { + DisplayUser(userHex, null, accountViewModel, nav) + } + } + + fun renderObservableShortNoteUri( + loadedLink: LoadedBechLink, + uri: String, + richTextStringBuilder: RichTextString.Builder, + ) { + loadedLink.baseNote?.let { renderNoteObserver(it, richTextStringBuilder) } + renderShortNostrURI(uri, richTextStringBuilder) + } + + private fun renderNoteObserver( + baseNote: Note, + richTextStringBuilder: RichTextString.Builder, + ) { + renderInvisible(richTextStringBuilder) { + // Preloads note if not loaded yet. + baseNote.live().metadata.observeAsState() + } + } + + private fun renderShortNostrURI( + uri: String, + richTextStringBuilder: RichTextString.Builder, + ) { + val nip19 = "@" + uri.removePrefix("nostr:") + + renderAsCompleteLink( + title = + if (nip19.length > 16) { + nip19.replaceRange(8, nip19.length - 8, ":") + } else { + nip19 + }, + destination = uri, + richTextStringBuilder = richTextStringBuilder, + ) + } + + private fun renderInvisible( + richTextStringBuilder: RichTextString.Builder, + innerComposable: @Composable () -> Unit, + ) { + richTextStringBuilder.appendInlineContent( + content = + InlineContent( + initialSize = { + IntSize(0.dp.roundToPx(), 0.dp.roundToPx()) + }, + ) { + innerComposable() + }, + ) + } + + private fun renderInline( + richTextStringBuilder: RichTextString.Builder, + innerComposable: @Composable () -> Unit, + ) { + richTextStringBuilder.appendInlineContent( + content = + InlineContent( + initialSize = { + IntSize(Font17SP.roundToPx(), Font17SP.roundToPx()) + }, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ) { + innerComposable() + }, + ) + } + + private fun renderInlineFullWidth( + richTextStringBuilder: RichTextString.Builder, + innerComposable: @Composable () -> Unit, + ) { + richTextStringBuilder.appendInlineContentFullWidth( + content = + InlineContent( + initialSize = { + IntSize(Font17SP.roundToPx(), Font17SP.roundToPx()) + }, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ) { + innerComposable() + }, + ) + } + + private fun renderAsCompleteLink( + title: String, + destination: String, + richTextStringBuilder: RichTextString.Builder, + ) { + richTextStringBuilder.pushFormat( + RichTextString.Format.Link(destination = destination), + ) + richTextStringBuilder.append(title) + richTextStringBuilder.pop() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt new file mode 100644 index 000000000..56735f92a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components.markdown + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser +import com.halilibo.richtext.commonmark.MarkdownParseOptions +import com.halilibo.richtext.markdown.BasicMarkdown +import com.halilibo.richtext.ui.material3.RichText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle +import com.vitorpamplona.amethyst.ui.theme.markdownStyle +import com.vitorpamplona.amethyst.ui.uriToRoute +import com.vitorpamplona.quartz.events.ImmutableListOfLists + +@Composable +fun RenderContentAsMarkdown( + content: String, + tags: ImmutableListOfLists?, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val uri = LocalUriHandler.current + val onClick = + remember { + { link: String -> + val route = uriToRoute(link) + if (route != null) { + nav(route) + } else { + runCatching { uri.openUri(link) } + } + Unit + } + } + + ProvideTextStyle(MarkdownTextStyle) { + val astNode = + remember(content) { + CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content) + } + + val renderer = + remember(content) { + MarkdownMediaRenderer( + content.take(100), + tags, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + } + + RichText( + style = MaterialTheme.colorScheme.markdownStyle, + linkClickHandler = onClick, + renderer = renderer, + ) { + BasicMarkdown(astNode) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index 7ce9782aa..8e3b74565 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -48,6 +48,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -82,9 +83,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.fragment.app.FragmentActivity -import com.halilibo.richtext.markdown.Markdown +import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser +import com.halilibo.richtext.commonmark.MarkdownParseOptions +import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.ui.RichTextStyle -import com.halilibo.richtext.ui.material3.Material3RichText +import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.resolveDefaults import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account @@ -140,12 +143,18 @@ fun AccountBackupDialog( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Material3RichText( + val content1 = stringResource(R.string.account_backup_tips2_md) + + val astNode1 = + remember { + CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content1) + } + + RichText( style = RichTextStyle().resolveDefaults(), + renderer = null, ) { - Markdown( - content = stringResource(R.string.account_backup_tips2_md), - ) + BasicMarkdown(astNode1) } Spacer(modifier = Modifier.height(10.dp)) @@ -154,12 +163,18 @@ fun AccountBackupDialog( Spacer(modifier = Modifier.height(30.dp)) - Material3RichText( + val content = stringResource(R.string.account_backup_tips3_md) + + val astNode = + remember { + CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content) + } + + RichText( style = RichTextStyle().resolveDefaults(), + renderer = null, ) { - Markdown( - content = stringResource(R.string.account_backup_tips3_md), - ) + BasicMarkdown(astNode) } Spacer(modifier = Modifier.height(10.dp)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 83e5a0750..f77b86d0b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -59,7 +59,6 @@ import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.components.BundledInsert -import com.vitorpamplona.amethyst.ui.components.MarkdownParser import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.bottomNavigationItems @@ -79,7 +78,6 @@ import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.GiftWrapEvent -import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.Participant @@ -1015,26 +1013,6 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View } } - fun returnNIP19References( - content: String, - tags: ImmutableListOfLists?, - onNewReferences: (List) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - onNewReferences(MarkdownParser().returnNIP19References(content, tags)) - } - } - - fun returnMarkdownWithSpecialContent( - content: String, - tags: ImmutableListOfLists?, - onNewContent: (String) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - onNewContent(MarkdownParser().returnMarkdownWithSpecialContent(content, tags)) - } - } - fun checkIsOnline( media: String?, onDone: (Boolean) -> Unit, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt index 5815c9fc7..0aa0ebf05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -47,8 +48,10 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import com.halilibo.richtext.markdown.Markdown -import com.halilibo.richtext.ui.material3.Material3RichText +import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser +import com.halilibo.richtext.commonmark.MarkdownParseOptions +import com.halilibo.richtext.markdown.BasicMarkdown +import com.halilibo.richtext.ui.material3.RichText import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -112,12 +115,18 @@ fun ConnectOrbotDialog( ) Row { - Material3RichText( + val content1 = stringResource(R.string.connect_through_your_orbot_setup_markdown) + + val astNode1 = + remember { + CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content1) + } + + RichText( style = myMarkDownStyle, + renderer = null, ) { - Markdown( - content = stringResource(R.string.connect_through_your_orbot_setup_markdown), - ) + BasicMarkdown(astNode1) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 57c6d7d80..e027cee5e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -36,6 +36,8 @@ import androidx.compose.material3.Shapes import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.unit.dp val Shapes = @@ -231,3 +233,10 @@ val liveStreamTag = val chatAuthorBox = Modifier.size(20.dp) val chatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape) val AuthorInfoVideoFeed = Modifier.width(75.dp).padding(end = 15.dp) + +val inlinePlaceholder = + Placeholder( + width = Font17SP, + height = Font17SP, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index f30ebf75d..2a2df4882 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -59,7 +59,7 @@ val Font18SP = 18.sp val MarkdownTextStyle = TextStyle(lineHeight = 1.30.em) -val DefaultParagraphSpacing: TextUnit = 16.sp +val DefaultParagraphSpacing: TextUnit = 18.sp internal val DefaultHeadingStyle: HeadingStyle = { level, textStyle -> when (level) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 876c763cd..329f88a19 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ kotlinxCollectionsImmutable = "0.3.7" languageId = "17.0.5" lazysodiumAndroid = "5.1.0" lightcompressor = "1.3.2" -markdown = "48702a8ced" +markdown = "077a2cde64" media3 = "1.3.0" mockk = "1.13.10" navigationCompose = "2.7.7"