From 90175198f0b3e5a726ddb221ae0c0db25e68d306 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 21 Feb 2024 15:25:40 -0500 Subject: [PATCH] Preparing to move Kind1 content parsers to commons. --- .../amethyst/RichTextParserTest.kt | 4 +- .../amethyst/service/CachedRichTextParser.kt | 334 +---------------- .../amethyst/ui/actions/NewPostView.kt | 19 +- .../amethyst/ui/actions/NewPostViewModel.kt | 5 +- .../amethyst/ui/components/LoadUrlPreview.kt | 2 + .../amethyst/ui/components/MarkdownParser.kt | 19 +- .../amethyst/ui/components/RichTextViewer.kt | 76 +--- .../ui/components/ZoomableContentView.kt | 109 +----- .../amethyst/ui/note/NoteCompose.kt | 28 +- .../ui/screen/loggedIn/ChannelScreen.kt | 2 +- .../ui/screen/loggedIn/ProfileScreen.kt | 6 +- commons/build.gradle | 6 + .../amethyst/commons/RichTextParser.kt | 352 ++++++++++++++++++ .../commons/RichTextParserSegments.kt | 90 +++++ .../amethyst/commons/ZoomableContentModels.kt | 98 +++++ 15 files changed, 616 insertions(+), 534 deletions(-) create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParserSegments.kt create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/ZoomableContentModels.kt diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt index b7ae07036..5df0efdfb 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.service.RichTextParser -import com.vitorpamplona.amethyst.service.RichTextViewerState +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.commons.RichTextViewerState import com.vitorpamplona.quartz.events.EmptyTagList import org.junit.Assert import org.junit.Test diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index 07c34eaf5..25e6c6050 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -20,45 +20,10 @@ */ package com.vitorpamplona.amethyst.service -import android.util.Log import android.util.LruCache -import android.util.Patterns -import androidx.compose.runtime.Immutable -import com.linkedin.urls.detection.UrlDetector -import com.linkedin.urls.detection.UrlDetectorOptions -import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent -import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage -import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo -import com.vitorpamplona.amethyst.ui.components.hashTagsPattern -import com.vitorpamplona.amethyst.ui.components.imageExtensions -import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison -import com.vitorpamplona.amethyst.ui.components.tagIndex -import com.vitorpamplona.amethyst.ui.components.videoExtensions -import com.vitorpamplona.quartz.encoders.Nip54 -import com.vitorpamplona.quartz.encoders.Nip92 -import com.vitorpamplona.quartz.events.FileHeaderEvent +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.commons.RichTextViewerState import com.vitorpamplona.quartz.events.ImmutableListOfLists -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toImmutableMap -import kotlinx.collections.immutable.toImmutableSet -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.CancellationException -import java.util.regex.Pattern - -@Immutable -data class RichTextViewerState( - val urlSet: ImmutableSet, - val imagesForPager: ImmutableMap, - val imageList: ImmutableList, - val customEmoji: ImmutableMap, - val paragraphs: ImmutableList, -) - -data class ParagraphState(val words: ImmutableList, val isRTL: Boolean) object CachedRichTextParser { val richTextCache = LruCache(200) @@ -76,298 +41,3 @@ object CachedRichTextParser { } } } - -// Group 1 = url, group 4 additional chars -// val noProtocolUrlValidator = -// Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)") - -// Android9 seems to have an issue starting this regex. -val noProtocolUrlValidator = - try { - Pattern.compile( - "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)", - ) - } catch (e: Exception) { - Pattern.compile( - "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)", - ) - } - -val HTTPRegex = - "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?" - .toRegex(RegexOption.IGNORE_CASE) - -class RichTextParser() { - fun parseMediaUrl( - fullUrl: String, - eventTags: ImmutableListOfLists, - ): ZoomableUrlContent? { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - val frags = Nip54().parse(fullUrl) - val tags = Nip92().parse(fullUrl, eventTags.lists) - - ZoomableUrlImage( - url = fullUrl, - description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], - hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], - blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], - dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], - contentWarning = frags["content-warning"] ?: tags["content-warning"], - ) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - val frags = Nip54().parse(fullUrl) - val tags = Nip92().parse(fullUrl, eventTags.lists) - ZoomableUrlVideo( - url = fullUrl, - description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], - hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], - blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], - dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], - contentWarning = frags["content-warning"] ?: tags["content-warning"], - ) - } else { - null - } - } - - fun parseText( - content: String, - tags: ImmutableListOfLists, - ): RichTextViewerState { - val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() - - val urlSet = - urls.mapNotNullTo(LinkedHashSet(urls.size)) { - // removes e-mails - if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) { - null - } else if (isNumber(it.originalUrl)) { - null - } else if (it.originalUrl.contains("。")) { - null - } else { - if (HTTPRegex.matches(it.originalUrl)) { - it.originalUrl - } else { - null - } - } - } - - val imagesForPager = - urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url } - val imageList = imagesForPager.values.toList() - - val emojiMap = - tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } - - val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) - - return RichTextViewerState( - urlSet.toImmutableSet(), - imagesForPager.toImmutableMap(), - imageList.toImmutableList(), - emojiMap.toImmutableMap(), - segments, - ) - } - - private fun findTextSegments( - content: String, - images: Set, - urls: Set, - emojis: Map, - tags: ImmutableListOfLists, - ): ImmutableList { - val lines = content.split('\n') - val paragraphSegments = ArrayList(lines.size) - - lines.forEach { paragraph -> - var isDirty = false - val isRTL = isArabic(paragraph) - - val wordList = paragraph.trimEnd().split(' ') - val segments = ArrayList(wordList.size) - wordList.forEach { word -> - val wordSegment = wordIdentifier(word, images, urls, emojis, tags) - if (wordSegment !is RegularTextSegment) { - isDirty = true - } - segments.add(wordSegment) - } - - val newSegments = - if (isDirty) { - ParagraphState(segments.toPersistentList(), isRTL) - } else { - ParagraphState(persistentListOf(RegularTextSegment(paragraph)), isRTL) - } - - paragraphSegments.add(newSegments) - } - - return paragraphSegments.toImmutableList() - } - - fun isNumber(word: String): Boolean { - return numberPattern.matcher(word).matches() - } - - fun isDate(word: String): Boolean { - return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches() - } - - private fun isArabic(text: String): Boolean { - return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } - } - - private fun wordIdentifier( - word: String, - images: Set, - urls: Set, - emojis: Map, - tags: ImmutableListOfLists, - ): Segment { - val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word) - val phoneMatcher = Patterns.PHONE.matcher(word) - val schemelessMatcher = noProtocolUrlValidator.matcher(word) - - return if (word.isEmpty()) { - RegularTextSegment(word) - } else if (images.contains(word)) { - ImageSegment(word) - } else if (urls.contains(word)) { - LinkSegment(word) - } else if (emojis.any { word.contains(it.key) }) { - EmojiSegment(word) - } else if (word.startsWith("lnbc", true)) { - InvoiceSegment(word) - } else if (word.startsWith("lnurl", true)) { - WithdrawSegment(word) - } else if (word.startsWith("cashuA", true)) { - CashuSegment(word) - } else if (emailMatcher.matches()) { - EmailSegment(word) - } else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) { - PhoneSegment(word) - } else if (startsWithNIP19Scheme(word)) { - BechSegment(word) - } else if (word.startsWith("#")) { - parseHash(word, tags) - } else if (word.contains(".") && schemelessMatcher.find()) { - val url = schemelessMatcher.group(1) // url - val additionalChars = schemelessMatcher.group(4).ifEmpty { null } // additional chars - val pattern = - """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""" - .toRegex(RegexOption.IGNORE_CASE) - if (pattern.find(word) != null) { - SchemelessUrlSegment(word, url, additionalChars) - } else { - RegularTextSegment(word) - } - } else { - RegularTextSegment(word) - } - } - - private fun parseHash( - word: String, - tags: ImmutableListOfLists, - ): Segment { - // First #[n] - - val matcher = tagIndex.matcher(word) - try { - if (matcher.find()) { - val index = matcher.group(1)?.toInt() - val suffix = matcher.group(2) - - if (index != null && index >= 0 && index < tags.lists.size) { - val tag = tags.lists[index] - - if (tag.size > 1) { - if (tag[0] == "p") { - return HashIndexUserSegment(word, tag[1], suffix) - } else if (tag[0] == "e" || tag[0] == "a") { - return HashIndexEventSegment(word, tag[1], suffix) - } - } - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("Tag Parser", "Couldn't link tag $word", e) - } - - // Second #Amethyst - val hashtagMatcher = hashTagsPattern.matcher(word) - - try { - if (hashtagMatcher.find()) { - val hashtag = hashtagMatcher.group(1) - if (hashtag != null) { - return HashTagSegment(word, hashtag, hashtagMatcher.group(2).ifEmpty { null }) - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) - } - - return RegularTextSegment(word) - } - - companion object { - val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$") - val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$") - val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$") - } -} - -@Immutable open class Segment(val segmentText: String) - -@Immutable class ImageSegment(segment: String) : Segment(segment) - -@Immutable class LinkSegment(segment: String) : Segment(segment) - -@Immutable class EmojiSegment(segment: String) : Segment(segment) - -@Immutable class InvoiceSegment(segment: String) : Segment(segment) - -@Immutable class WithdrawSegment(segment: String) : Segment(segment) - -@Immutable class CashuSegment(segment: String) : Segment(segment) - -@Immutable class EmailSegment(segment: String) : Segment(segment) - -@Immutable class PhoneSegment(segment: String) : Segment(segment) - -@Immutable class BechSegment(segment: String) : Segment(segment) - -@Immutable -open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : - Segment(segment) - -@Immutable -class HashIndexUserSegment(segment: String, hex: String, extras: String?) : - HashIndexSegment(segment, hex, extras) - -@Immutable -class HashIndexEventSegment(segment: String, hex: String, extras: String?) : - HashIndexSegment(segment, hex, extras) - -@Immutable -class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment) - -@Immutable -class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : - Segment(segment) - -@Immutable class RegularTextSegment(segment: String) : Segment(segment) - -fun startsWithNIP19Scheme(word: String): Boolean { - val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@") - - return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 392f17741..5ebf74489 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -129,23 +129,18 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil -import com.vitorpamplona.amethyst.service.noProtocolUrlValidator -import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview 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 import com.vitorpamplona.amethyst.ui.note.CloseIcon @@ -476,10 +471,8 @@ fun NewPostView( val myUrlPreview = postViewModel.urlPreview if (myUrlPreview != null) { Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { - if (isValidURL(myUrlPreview)) { - val removedParamsFromUrl = - removeQueryParamsForExtensionComparison(myUrlPreview) - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + if (RichTextParser.isValidURL(myUrlPreview)) { + if (RichTextParser.isImageUrl(myUrlPreview)) { AsyncImage( model = myUrlPreview, contentDescription = myUrlPreview, @@ -495,7 +488,7 @@ fun NewPostView( QuoteBorder, ), ) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + } else if (RichTextParser.isVideoUrl(myUrlPreview)) { VideoView( myUrlPreview, roundedCorner = true, @@ -504,7 +497,7 @@ fun NewPostView( } else { LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel) } - } else if (startsWithNIP19Scheme(myUrlPreview)) { + } else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) { val bgColor = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(bgColor) } @@ -515,7 +508,7 @@ fun NewPostView( accountViewModel, nav, ) - } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { + } else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) { LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 6dc44868d..f8ee6072e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.fonfon.kgeohash.toGeoHash +import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -43,11 +44,9 @@ import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.LocationUtil import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.noProtocolUrlValidator import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.Split -import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.AddressableEvent @@ -540,7 +539,7 @@ open class NewPostViewModel() : ViewModel() { open fun findUrlInMessage(): String? { return message.text.split('\n').firstNotNullOfOrNull { paragraph -> paragraph.split(' ').firstOrNull { word: String -> - isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() + RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index 9710607e2..f48b52a74 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -29,6 +29,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.commons.ZoomableUrlImage +import com.vitorpamplona.amethyst.commons.ZoomableUrlVideo import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding 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 index a45aa6c81..ec1d97c94 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MarkdownParser.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.components import android.util.Log import android.util.Patterns +import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.events.ImmutableListOfLists import kotlinx.coroutines.CancellationException @@ -34,7 +34,7 @@ class MarkdownParser { tag: String, tags: ImmutableListOfLists, ): Pair? { - val matcher = tagIndex.matcher(tag) + val matcher = RichTextParser.tagIndex.matcher(tag) val (index, suffix) = try { matcher.find() @@ -95,7 +95,7 @@ class MarkdownParser { val listOfReferences = mutableListOf() content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> - if (startsWithNIP19Scheme(word)) { + if (RichTextParser.startsWithNIP19Scheme(word)) { val parsedNip19 = Nip19.uriToRoute(word) parsedNip19?.let { listOfReferences.add(it) } } @@ -122,9 +122,8 @@ class MarkdownParser { var returnContent = "" content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> - if (isValidURL(word)) { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word) - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + if (RichTextParser.isValidURL(word)) { + if (RichTextParser.isImageUrl(word)) { returnContent += "![]($word) " } else { returnContent += "[$word]($word) " @@ -133,7 +132,7 @@ class MarkdownParser { returnContent += "[$word](mailto:$word) " } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { returnContent += "[$word](tel:$word) " - } else if (startsWithNIP19Scheme(word)) { + } else if (RichTextParser.startsWithNIP19Scheme(word)) { val parsedNip19 = Nip19.uriToRoute(word) returnContent += if (parsedNip19 !== null) { @@ -148,15 +147,15 @@ class MarkdownParser { "$word " } } else if (word.startsWith("#")) { - if (tagIndex.matcher(word).matches() && tags != null) { + 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 (hashTagsPattern.matcher(word).matches()) { - val hashtagMatcher = hashTagsPattern.matcher(word) + } else if (RichTextParser.hashTagsPattern.matcher(word).matches()) { + val hashtagMatcher = RichTextParser.hashTagsPattern.matcher(word) val (myTag, mySuffix) = try { 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 8efebac48..01164e84c 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 @@ -68,28 +68,29 @@ import androidx.compose.ui.unit.em import com.halilibo.richtext.markdown.Markdown import com.halilibo.richtext.markdown.MarkdownParseOptions import com.halilibo.richtext.ui.material3.Material3RichText +import com.vitorpamplona.amethyst.commons.BechSegment +import com.vitorpamplona.amethyst.commons.CashuSegment +import com.vitorpamplona.amethyst.commons.EmailSegment +import com.vitorpamplona.amethyst.commons.EmojiSegment +import com.vitorpamplona.amethyst.commons.HashIndexEventSegment +import com.vitorpamplona.amethyst.commons.HashIndexUserSegment +import com.vitorpamplona.amethyst.commons.HashTagSegment +import com.vitorpamplona.amethyst.commons.ImageSegment +import com.vitorpamplona.amethyst.commons.InvoiceSegment +import com.vitorpamplona.amethyst.commons.LinkSegment +import com.vitorpamplona.amethyst.commons.PhoneSegment +import com.vitorpamplona.amethyst.commons.RegularTextSegment +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.commons.RichTextViewerState +import com.vitorpamplona.amethyst.commons.SchemelessUrlSegment +import com.vitorpamplona.amethyst.commons.Segment +import com.vitorpamplona.amethyst.commons.WithdrawSegment +import com.vitorpamplona.amethyst.commons.ZoomableUrlImage import com.vitorpamplona.amethyst.model.HashtagIcon import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon -import com.vitorpamplona.amethyst.service.BechSegment import com.vitorpamplona.amethyst.service.CachedRichTextParser -import com.vitorpamplona.amethyst.service.CashuSegment -import com.vitorpamplona.amethyst.service.EmailSegment -import com.vitorpamplona.amethyst.service.EmojiSegment -import com.vitorpamplona.amethyst.service.HashIndexEventSegment -import com.vitorpamplona.amethyst.service.HashIndexUserSegment -import com.vitorpamplona.amethyst.service.HashTagSegment -import com.vitorpamplona.amethyst.service.ImageSegment -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 -import com.vitorpamplona.amethyst.service.WithdrawSegment import com.vitorpamplona.amethyst.ui.note.LoadUser import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.toShortenHex @@ -106,45 +107,6 @@ import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.net.MalformedURLException -import java.net.URISyntaxException -import java.net.URL -import java.util.regex.Pattern - -val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") -val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") - -val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") -val hashTagsPattern: Pattern = - Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) - -fun removeQueryParamsForExtensionComparison(fullUrl: String): String { - return if (fullUrl.contains("?")) { - fullUrl.split("?")[0].lowercase() - } else if (fullUrl.contains("#")) { - fullUrl.split("#")[0].lowercase() - } else { - fullUrl.lowercase() - } -} - -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() - true - } catch (e: MalformedURLException) { - false - } catch (e: URISyntaxException) { - false - } -} fun isMarkdown(content: String): Boolean { return content.startsWith("> ") || @@ -420,7 +382,7 @@ fun RenderCustomEmoji( val markdownParseOptions = MarkdownParseOptions( autolink = true, - isImage = { url -> isImageOrVideoUrl(url) }, + isImage = { url -> RichTextParser.isImageOrVideoUrl(url) }, ) @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 50a308be7..ac9715ba3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -69,7 +69,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.SideEffect @@ -107,6 +106,13 @@ import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.imageLoader import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ZoomableContent +import com.vitorpamplona.amethyst.commons.ZoomableLocalImage +import com.vitorpamplona.amethyst.commons.ZoomableLocalVideo +import com.vitorpamplona.amethyst.commons.ZoomablePreloadedContent +import com.vitorpamplona.amethyst.commons.ZoomableUrlContent +import com.vitorpamplona.amethyst.commons.ZoomableUrlImage +import com.vitorpamplona.amethyst.commons.ZoomableUrlVideo import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.InformationDialog @@ -137,93 +143,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -import java.io.File - -@Immutable -abstract class ZoomableContent( - val description: String? = null, - val dim: String? = null, -) - -@Immutable -abstract class ZoomableUrlContent( - val url: String, - description: String? = null, - val hash: String? = null, - dim: String? = null, - val uri: String? = null, -) : ZoomableContent(description, dim) - -@Immutable -class ZoomableUrlImage( - url: String, - description: String? = null, - hash: String? = null, - val blurhash: String? = null, - dim: String? = null, - uri: String? = null, - val contentWarning: String? = null, -) : ZoomableUrlContent(url, description, hash, dim, uri) - -@Immutable -class ZoomableUrlVideo( - url: String, - description: String? = null, - hash: String? = null, - dim: String? = null, - uri: String? = null, - val artworkUri: String? = null, - val authorName: String? = null, - val blurhash: String? = null, - val contentWarning: String? = null, -) : ZoomableUrlContent(url, description, hash, dim, uri) - -@Immutable -abstract class ZoomablePreloadedContent( - val localFile: File?, - description: String? = null, - val mimeType: String? = null, - val isVerified: Boolean? = null, - dim: String? = null, - val uri: String, -) : ZoomableContent(description, dim) - -@Immutable -class ZoomableLocalImage( - localFile: File?, - mimeType: String? = null, - description: String? = null, - val blurhash: String? = null, - dim: String? = null, - isVerified: Boolean? = null, - uri: String, -) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) - -@Immutable -class ZoomableLocalVideo( - localFile: File?, - mimeType: String? = null, - description: String? = null, - dim: String? = null, - isVerified: Boolean? = null, - uri: String, - val artworkUri: String? = null, - val authorName: String? = null, -) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) - -fun figureOutMimeType(fullUrl: String): ZoomableContent { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } - - return if (isImage) { - ZoomableUrlImage(fullUrl) - } else if (isVideo) { - ZoomableUrlVideo(fullUrl) - } else { - ZoomableUrlImage(fullUrl) - } -} @Composable @OptIn(ExperimentalFoundationApi::class) @@ -316,7 +235,7 @@ private fun LocalImageView( accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, ) { - if (content.localFile != null && content.localFile.exists()) { + if (content.localFileExists()) { BoxWithConstraints(contentAlignment = Alignment.Center) { val showImage = remember { @@ -549,8 +468,8 @@ private fun AddedImageFeatures( BlankNote() } is AsyncImagePainter.State.Success -> { - if (content.isVerified != null) { - HashVerificationSymbol(content.isVerified, verifiedModifier) + content.isVerified?.let { + HashVerificationSymbol(it, verifiedModifier) } } else -> {} @@ -888,9 +807,9 @@ private fun DialogContent( Spacer(modifier = StdHorzSpacer) SaveToGallery(url = myContent.url) } - } else if (myContent is ZoomableLocalImage && myContent.localFile != null) { + } else if (myContent is ZoomableLocalImage && myContent.localFileExists()) { SaveToGallery( - localFile = myContent.localFile, + localFile = myContent.localFile!!, mimeType = myContent.mimeType, ) } @@ -975,11 +894,11 @@ private fun ShareImageAction( onDismiss() }, ) - if (content.uri != null) { + content.uri?.let { DropdownMenuItem( text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, onClick = { - clipboardManager.setText(AnnotatedString(content.uri)) + clipboardManager.setText(AnnotatedString(it)) onDismiss() }, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 08f07f767..80eb86c8f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -95,6 +95,12 @@ import coil.request.SuccessResult import com.fonfon.kgeohash.GeoHash import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.commons.ZoomableContent +import com.vitorpamplona.amethyst.commons.ZoomableLocalImage +import com.vitorpamplona.amethyst.commons.ZoomableLocalVideo +import com.vitorpamplona.amethyst.commons.ZoomableUrlImage +import com.vitorpamplona.amethyst.commons.ZoomableUrlVideo import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note @@ -113,17 +119,9 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ShowMoreButton import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.VideoView -import com.vitorpamplona.amethyst.ui.components.ZoomableContent import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage -import com.vitorpamplona.amethyst.ui.components.ZoomableLocalVideo -import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage -import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo -import com.vitorpamplona.amethyst.ui.components.figureOutMimeType -import com.vitorpamplona.amethyst.ui.components.imageExtensions import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth -import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison import com.vitorpamplona.amethyst.ui.elements.AddButton import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost @@ -1447,7 +1445,7 @@ fun RenderAppDefinition( if (zoomImageDialogOpen) { ZoomableImageDialog( - imageUrl = figureOutMimeType(it.banner!!), + imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) @@ -1503,7 +1501,7 @@ fun RenderAppDefinition( if (zoomImageDialogOpen) { ZoomableImageDialog( - imageUrl = figureOutMimeType(it.banner!!), + imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) @@ -3064,10 +3062,7 @@ fun FileHeaderDisplay( val hash = event.hash() val dimensions = event.dimensions() val description = event.alt() ?: event.content - val isImage = - imageExtensions.any { - removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) - } + val isImage = RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() mutableStateOf( @@ -3126,10 +3121,7 @@ fun VideoDisplay( val hash = event.hash() val dimensions = event.dimensions() val description = event.alt() ?: event.content - val isImage = - imageExtensions.any { - removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) - } + val isImage = RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() mutableStateOf( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index ed4fb042b..561e4bdeb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -101,6 +101,7 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ZoomableUrlVideo import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LiveActivitiesChannel @@ -120,7 +121,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 217eac68b..094682cbe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -112,6 +112,7 @@ import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache @@ -127,7 +128,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.components.figureOutMimeType import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture @@ -826,7 +826,7 @@ private fun ProfileHeader( val profilePic = baseUser.profilePicture() if (zoomImageDialogOpen && profilePic != null) { ZoomableImageDialog( - figureOutMimeType(profilePic), + RichTextParser.parseImageOrVideo(profilePic), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) @@ -1477,7 +1477,7 @@ fun DrawBanner( if (zoomImageDialogOpen) { ZoomableImageDialog( - imageUrl = figureOutMimeType(banner), + imageUrl = RichTextParser.parseImageOrVideo(banner), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) diff --git a/commons/build.gradle b/commons/build.gradle index de8dde629..a30b159dd 100644 --- a/commons/build.gradle +++ b/commons/build.gradle @@ -37,6 +37,12 @@ android { dependencies { implementation project(path: ':quartz') + // Import @Immutable and @Stable + implementation "androidx.compose.ui:ui:$compose_ui_version" + + // immutable collections to avoid recomposition + api('org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7') + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt new file mode 100644 index 000000000..20d9721a6 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt @@ -0,0 +1,352 @@ +/** + * 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.commons + +import android.util.Log +import android.util.Patterns +import com.linkedin.urls.detection.UrlDetector +import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.quartz.encoders.Nip54 +import com.vitorpamplona.quartz.encoders.Nip92 +import com.vitorpamplona.quartz.events.FileHeaderEvent +import com.vitorpamplona.quartz.events.ImmutableListOfLists +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.collections.immutable.toPersistentList +import java.net.MalformedURLException +import java.net.URISyntaxException +import java.net.URL +import java.util.regex.Pattern +import kotlin.coroutines.cancellation.CancellationException + +class RichTextParser() { + fun parseMediaUrl( + fullUrl: String, + eventTags: ImmutableListOfLists, + ): ZoomableUrlContent? { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) + return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + val frags = Nip54().parse(fullUrl) + val tags = Nip92().parse(fullUrl, eventTags.lists) + + ZoomableUrlImage( + url = fullUrl, + description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], + hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], + blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], + dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], + contentWarning = frags["content-warning"] ?: tags["content-warning"], + ) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + val frags = Nip54().parse(fullUrl) + val tags = Nip92().parse(fullUrl, eventTags.lists) + ZoomableUrlVideo( + url = fullUrl, + description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT], + hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH], + blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH], + dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], + contentWarning = frags["content-warning"] ?: tags["content-warning"], + ) + } else { + null + } + } + + fun parseText( + content: String, + tags: ImmutableListOfLists, + ): RichTextViewerState { + val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() + + val urlSet = + urls.mapNotNullTo(LinkedHashSet(urls.size)) { + // removes e-mails + if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) { + null + } else if (isNumber(it.originalUrl)) { + null + } else if (it.originalUrl.contains("。")) { + null + } else { + if (HTTPRegex.matches(it.originalUrl)) { + it.originalUrl + } else { + null + } + } + } + + val imagesForPager = + urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url } + val imageList = imagesForPager.values.toList() + + val emojiMap = + tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } + + val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) + + return RichTextViewerState( + urlSet.toImmutableSet(), + imagesForPager.toImmutableMap(), + imageList.toImmutableList(), + emojiMap.toImmutableMap(), + segments, + ) + } + + private fun findTextSegments( + content: String, + images: Set, + urls: Set, + emojis: Map, + tags: ImmutableListOfLists, + ): ImmutableList { + val lines = content.split('\n') + val paragraphSegments = ArrayList(lines.size) + + lines.forEach { paragraph -> + var isDirty = false + val isRTL = isArabic(paragraph) + + val wordList = paragraph.trimEnd().split(' ') + val segments = ArrayList(wordList.size) + wordList.forEach { word -> + val wordSegment = wordIdentifier(word, images, urls, emojis, tags) + if (wordSegment !is RegularTextSegment) { + isDirty = true + } + segments.add(wordSegment) + } + + val newSegments = + if (isDirty) { + ParagraphState(segments.toPersistentList(), isRTL) + } else { + ParagraphState(persistentListOf(RegularTextSegment(paragraph)), isRTL) + } + + paragraphSegments.add(newSegments) + } + + return paragraphSegments.toImmutableList() + } + + fun isNumber(word: String): Boolean { + return numberPattern.matcher(word).matches() + } + + fun isDate(word: String): Boolean { + return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches() + } + + private fun isArabic(text: String): Boolean { + return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } + } + + private fun wordIdentifier( + word: String, + images: Set, + urls: Set, + emojis: Map, + tags: ImmutableListOfLists, + ): Segment { + val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word) + val phoneMatcher = Patterns.PHONE.matcher(word) + val schemelessMatcher = noProtocolUrlValidator.matcher(word) + + return if (word.isEmpty()) { + RegularTextSegment(word) + } else if (images.contains(word)) { + ImageSegment(word) + } else if (urls.contains(word)) { + LinkSegment(word) + } else if (emojis.any { word.contains(it.key) }) { + EmojiSegment(word) + } else if (word.startsWith("lnbc", true)) { + InvoiceSegment(word) + } else if (word.startsWith("lnurl", true)) { + WithdrawSegment(word) + } else if (word.startsWith("cashuA", true)) { + CashuSegment(word) + } else if (emailMatcher.matches()) { + EmailSegment(word) + } else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) { + PhoneSegment(word) + } else if (startsWithNIP19Scheme(word)) { + BechSegment(word) + } else if (word.startsWith("#")) { + parseHash(word, tags) + } else if (word.contains(".") && schemelessMatcher.find()) { + val url = schemelessMatcher.group(1) // url + val additionalChars = schemelessMatcher.group(4).ifEmpty { null } // additional chars + val pattern = + """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""" + .toRegex(RegexOption.IGNORE_CASE) + if (pattern.find(word) != null) { + SchemelessUrlSegment(word, url, additionalChars) + } else { + RegularTextSegment(word) + } + } else { + RegularTextSegment(word) + } + } + + private fun parseHash( + word: String, + tags: ImmutableListOfLists, + ): Segment { + // First #[n] + + val matcher = tagIndex.matcher(word) + try { + if (matcher.find()) { + val index = matcher.group(1)?.toInt() + val suffix = matcher.group(2) + + if (index != null && index >= 0 && index < tags.lists.size) { + val tag = tags.lists[index] + + if (tag.size > 1) { + if (tag[0] == "p") { + return HashIndexUserSegment(word, tag[1], suffix) + } else if (tag[0] == "e" || tag[0] == "a") { + return HashIndexEventSegment(word, tag[1], suffix) + } + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("Tag Parser", "Couldn't link tag $word", e) + } + + // Second #Amethyst + val hashtagMatcher = hashTagsPattern.matcher(word) + + try { + if (hashtagMatcher.find()) { + val hashtag = hashtagMatcher.group(1) + if (hashtag != null) { + return HashTagSegment(word, hashtag, hashtagMatcher.group(2).ifEmpty { null }) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) + } + + return RegularTextSegment(word) + } + + companion object { + val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$") + val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$") + val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$") + + // Android9 seems to have an issue starting this regex. + val noProtocolUrlValidator = + try { + Pattern.compile( + "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)", + ) + } catch (e: Exception) { + Pattern.compile( + "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)", + ) + } + + val HTTPRegex = + "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?" + .toRegex(RegexOption.IGNORE_CASE) + + val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") + val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") + + val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") + val hashTagsPattern: Pattern = + Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) + + fun removeQueryParamsForExtensionComparison(fullUrl: String): String { + return if (fullUrl.contains("?")) { + fullUrl.split("?")[0].lowercase() + } else if (fullUrl.contains("#")) { + fullUrl.split("#")[0].lowercase() + } else { + fullUrl.lowercase() + } + } + + fun isImageOrVideoUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + + return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || + videoExtensions.any { removedParamsFromUrl.endsWith(it) } + } + + fun isImageUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + return imageExtensions.any { removedParamsFromUrl.endsWith(it) } + } + + fun isVideoUrl(url: String): Boolean { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + return videoExtensions.any { removedParamsFromUrl.endsWith(it) } + } + + fun isValidURL(url: String?): Boolean { + return try { + URL(url).toURI() + true + } catch (e: MalformedURLException) { + false + } catch (e: URISyntaxException) { + false + } + } + + fun parseImageOrVideo(fullUrl: String): ZoomableContent { + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + + return if (isImage) { + ZoomableUrlImage(fullUrl) + } else if (isVideo) { + ZoomableUrlVideo(fullUrl) + } else { + ZoomableUrlImage(fullUrl) + } + } + + fun startsWithNIP19Scheme(word: String): Boolean { + val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@") + + return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } + } + + fun isUrlWithoutScheme(url: String) = noProtocolUrlValidator.matcher(url).matches() + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParserSegments.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParserSegments.kt new file mode 100644 index 000000000..e1b4419dd --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParserSegments.kt @@ -0,0 +1,90 @@ +/** + * 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.commons + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.ImmutableSet + +@Immutable +data class RichTextViewerState( + val urlSet: ImmutableSet, + val imagesForPager: ImmutableMap, + val imageList: ImmutableList, + val customEmoji: ImmutableMap, + val paragraphs: ImmutableList, +) + +@Immutable +data class ParagraphState(val words: ImmutableList, val isRTL: Boolean) + +@Immutable +open class Segment(val segmentText: String) + +@Immutable +class ImageSegment(segment: String) : Segment(segment) + +@Immutable +class LinkSegment(segment: String) : Segment(segment) + +@Immutable +class EmojiSegment(segment: String) : Segment(segment) + +@Immutable +class InvoiceSegment(segment: String) : Segment(segment) + +@Immutable +class WithdrawSegment(segment: String) : Segment(segment) + +@Immutable +class CashuSegment(segment: String) : Segment(segment) + +@Immutable +class EmailSegment(segment: String) : Segment(segment) + +@Immutable +class PhoneSegment(segment: String) : Segment(segment) + +@Immutable +class BechSegment(segment: String) : Segment(segment) + +@Immutable +open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : + Segment(segment) + +@Immutable +class HashIndexUserSegment(segment: String, hex: String, extras: String?) : + HashIndexSegment(segment, hex, extras) + +@Immutable +class HashIndexEventSegment(segment: String, hex: String, extras: String?) : + HashIndexSegment(segment, hex, extras) + +@Immutable +class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment) + +@Immutable +class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : + Segment(segment) + +@Immutable +class RegularTextSegment(segment: String) : Segment(segment) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/ZoomableContentModels.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/ZoomableContentModels.kt new file mode 100644 index 000000000..f3489c1cb --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/ZoomableContentModels.kt @@ -0,0 +1,98 @@ +/** + * 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.commons + +import androidx.compose.runtime.Immutable +import java.io.File + +@Immutable +abstract class ZoomableContent( + val description: String? = null, + val dim: String? = null, +) + +@Immutable +abstract class ZoomableUrlContent( + val url: String, + description: String? = null, + val hash: String? = null, + dim: String? = null, + val uri: String? = null, +) : ZoomableContent(description, dim) + +@Immutable +class ZoomableUrlImage( + url: String, + description: String? = null, + hash: String? = null, + val blurhash: String? = null, + dim: String? = null, + uri: String? = null, + val contentWarning: String? = null, +) : ZoomableUrlContent(url, description, hash, dim, uri) + +@Immutable +class ZoomableUrlVideo( + url: String, + description: String? = null, + hash: String? = null, + dim: String? = null, + uri: String? = null, + val artworkUri: String? = null, + val authorName: String? = null, + val blurhash: String? = null, + val contentWarning: String? = null, +) : ZoomableUrlContent(url, description, hash, dim, uri) + +@Immutable +abstract class ZoomablePreloadedContent( + val localFile: File?, + description: String? = null, + val mimeType: String? = null, + val isVerified: Boolean? = null, + dim: String? = null, + val uri: String, +) : ZoomableContent(description, dim) { + fun localFileExists() = localFile != null && localFile.exists() +} + +@Immutable +class ZoomableLocalImage( + localFile: File?, + mimeType: String? = null, + description: String? = null, + val blurhash: String? = null, + dim: String? = null, + isVerified: Boolean? = null, + uri: String, +) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) + +@Immutable +class ZoomableLocalVideo( + localFile: File?, + mimeType: String? = null, + description: String? = null, + dim: String? = null, + isVerified: Boolean? = null, + uri: String, + val artworkUri: String? = null, + val authorName: String? = null, +) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)