- Fixes the transition between short preview and full text on markdown.

- Adds NIP-44 metatags to markdown rendering.
- Adds background video rendering on markdown
- Refactors a few class names.
This commit is contained in:
Vitor Pamplona 2023-12-13 12:09:38 -05:00
parent 0e5b5d657f
commit acefff80ee
16 changed files with 112 additions and 77 deletions

View File

@ -233,9 +233,9 @@ dependencies {
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:48702a8ced')
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'

View File

@ -22,8 +22,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import java.net.URI
import java.net.URLDecoder
import java.util.regex.Pattern
@Immutable
@ -64,6 +62,31 @@ val noProtocolUrlValidator = try {
val HTTPRegex = "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
class RichTextParser() {
fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlImage(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"]
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlVideo(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"]
)
} else {
null
}
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>
@ -88,28 +111,7 @@ class RichTextParser() {
}
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = URI(fullUrl).fragments()
ZoomableUrlImage(
url = fullUrl,
description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") },
hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") },
blurhash = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") },
dim = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") }
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = URI(fullUrl).fragments()
ZoomableUrlVideo(
url = fullUrl,
description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") },
hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") },
blurhash = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") },
dim = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") }
)
} else {
null
}
parseMediaUrl(fullUrl)
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
@ -127,16 +129,6 @@ class RichTextParser() {
)
}
private fun URI.fragments(): Map<String, String> {
if (rawFragment == null) return emptyMap()
return rawFragment.split('&').associate {
val parts = it.split('=')
val name = parts.firstOrNull() ?: ""
val value = parts.getOrNull(1) ?: ""
Pair(name, value)
}
}
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>()

View File

@ -9,7 +9,7 @@ import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier() {
class Nip05NostrAddressVerifier() {
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")

View File

@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable
import java.util.regex.Pattern
@Immutable
class NIP30Parser {
class Nip30CustomEmoji {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {

View File

@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.service
import java.net.URI
import java.net.URLDecoder
class Nip44UrlParser {
fun parse(url: String): Map<String, String> {
return try {
fragments(URI(url))
} catch (e: Exception) {
emptyMap()
}
}
private fun fragments(uri: URI): Map<String, String> {
if (uri.rawFragment == null) return emptyMap()
return uri.rawFragment.split('&').associate { keyValuePair ->
val parts = keyValuePair.split('=')
val name = parts.firstOrNull() ?: ""
val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
Pair(name, value)
}
}
}

View File

@ -6,7 +6,7 @@ import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
// Rename to the corect nip number when ready.
object Nip47 {
object Nip47WalletConnectParser {
fun parse(uri: String): Nip47URI {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D

View File

@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.note.Nip47WalletConnectParser
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
@ -319,7 +319,7 @@ fun uriToRoute(uri: String?): String? {
}
} ?: try {
uri?.let {
Nip47.parse(it)
Nip47WalletConnectParser.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}

View File

@ -126,6 +126,7 @@ import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon
@ -460,14 +461,7 @@ fun NewPostView(
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl = if (myUrlPreview.contains("?")) {
myUrlPreview.split("?")[0].lowercase()
} else if (myUrlPreview.contains("#")) {
myUrlPreview.split("#")[0].lowercase()
} else {
myUrlPreview
}
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(myUrlPreview)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = myUrlPreview,

View File

@ -41,7 +41,7 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.service.Nip30CustomEmoji
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@ -550,7 +550,7 @@ fun CreateClickableTextWithEmoji(
}
suspend fun assembleAnnotatedList(text: String, emojis: Map<String, String>): ImmutableList<Renderable> {
return NIP30Parser().buildArray(text).map {
return Nip30CustomEmoji().buildArray(text).map {
val url = emojis[it]
if (url != null) {
ImageUrlType(url)

View File

@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -78,15 +79,17 @@ fun ExpandableRichTextViewer(
}
Box {
RichTextViewer(
text,
canPreview,
modifier.align(Alignment.TopStart),
tags,
backgroundColor,
accountViewModel,
nav
)
Crossfade(text, label = "ExpandableRichTextViewer") {
RichTextViewer(
it,
canPreview,
modifier.align(Alignment.TopStart),
tags,
backgroundColor,
accountViewModel,
nav
)
}
if (content.length > whereToCut && !showFullText) {
Row(

View File

@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.service.InvoiceSegment
import com.vitorpamplona.amethyst.service.LinkSegment
import com.vitorpamplona.amethyst.service.PhoneSegment
import com.vitorpamplona.amethyst.service.RegularTextSegment
import com.vitorpamplona.amethyst.service.RichTextParser
import com.vitorpamplona.amethyst.service.RichTextViewerState
import com.vitorpamplona.amethyst.service.SchemelessUrlSegment
import com.vitorpamplona.amethyst.service.Segment
@ -106,6 +107,12 @@ fun removeQueryParamsForExtensionComparison(fullUrl: String): String {
}
}
fun isImageOrVideoUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isValidURL(url: String?): Boolean {
return try {
URL(url).toURI()
@ -343,6 +350,13 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
)
}
val markdownParseOptions = MarkdownParseOptions(
autolink = true,
isImage = { url ->
isImageOrVideoUrl(url)
}
)
@Composable
private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<String>?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val uri = LocalUriHandler.current
@ -359,14 +373,22 @@ private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<
}
ProvideTextStyle(MarkdownTextStyle) {
Material3RichText(
style = MaterialTheme.colorScheme.markdownStyle
) {
Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) {
RefreshableContent(content, tags, accountViewModel) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default,
onLinkClicked = onClick
markdownParseOptions = markdownParseOptions,
onLinkClicked = onClick,
onMediaCompose = { title, destination ->
ZoomableContentView(
content = remember(destination) {
RichTextParser().parseMediaUrl(destination)
?: ZoomableUrlImage(url = destination)
},
roundedCorner = true,
accountViewModel = accountViewModel
)
}
)
}
}

View File

@ -176,7 +176,7 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
}
fun updateNIP47(uri: String) {
val contact = Nip47.parse(uri)
val contact = Nip47WalletConnectParser.parse(uri)
if (contact != null) {
walletConnectPubkey =
TextFieldValue(contact.pubKeyHex)

View File

@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker
@ -707,7 +707,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
val nip05 = userMetadata.nip05?.ifBlank { null } ?: return
viewModelScope.launch(Dispatchers.IO) {
Nip05Verifier().verifyNip05(
Nip05NostrAddressVerifier().verifyNip05(
nip05,
onSuccess = {
// Marks user as verified

View File

@ -16,12 +16,12 @@ import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
class Nip05VerifierTest {
class Nip05NostrAddressVerifierTest {
private val ALL_UPPER_CASE_USER_NAME = "ONETWO"
private val ALL_LOWER_CASE_USER_NAME = "onetwo"
@SpyK
var nip05Verifier = Nip05Verifier()
var nip05Verifier = Nip05NostrAddressVerifier()
@Before
fun setUp() {

View File

@ -10,7 +10,7 @@ class Nip30Test {
assertEquals(
listOf("Alex Gleason ", ":soapbox:", ""),
NIP30Parser().buildArray(input)
Nip30CustomEmoji().buildArray(input)
)
}
@ -20,7 +20,7 @@ class Nip30Test {
assertEquals(
listOf("", ":soapbox:", "Alex Gleason"),
NIP30Parser().buildArray(input)
Nip30CustomEmoji().buildArray(input)
)
}
@ -30,10 +30,10 @@ class Nip30Test {
assertEquals(
listOf("Hello ", ":gleasonator:", " 😂 ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"),
NIP30Parser().buildArray(input)
Nip30CustomEmoji().buildArray(input)
)
println(NIP30Parser().buildArray(input).joinToString(","))
println(Nip30CustomEmoji().buildArray(input).joinToString(","))
}
@Test()
@ -42,7 +42,7 @@ class Nip30Test {
assertEquals(
listOf("hello vitor: how can I help:"),
NIP30Parser().buildArray(input)
Nip30CustomEmoji().buildArray(input)
)
}
@ -52,7 +52,7 @@ class Nip30Test {
assertEquals(
listOf("\uD883\uDEDE\uD883\uDEDE麺の", ":x30EDE:", "。:\uD883\uDEDE:(Violation of NIP-30)"),
NIP30Parser().buildArray(input)
Nip30CustomEmoji().buildArray(input)
)
}
}