mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-03-30 12:36:00 +02:00
- 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:
parent
0e5b5d657f
commit
acefff80ee
@ -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'
|
||||
|
@ -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>()
|
||||
|
||||
|
@ -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("@")
|
||||
|
@ -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> {
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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() {
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user