mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
2 Commits
b6bf3b8a70
...
claude/opt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2602647f5 | ||
|
|
66fb5d6f85 |
@@ -69,7 +69,7 @@ class RichTextParser {
|
||||
isImage = fullUrl.startsWith("data:image/")
|
||||
isVideo = fullUrl.startsWith("data:video/")
|
||||
} else {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
val removedParamsFromUrl = stripUrlForExtensionCheck(fullUrl)
|
||||
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
@@ -169,34 +169,200 @@ class RichTextParser {
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): ImmutableList<ParagraphState> {
|
||||
val lines = content.split('\n')
|
||||
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
|
||||
val paragraphs = ArrayList<ParagraphState>()
|
||||
var lineStart = 0
|
||||
|
||||
lines.forEach { paragraph ->
|
||||
val isRTL = isArabic(paragraph)
|
||||
|
||||
val wordList = paragraph.trimEnd().split(' ')
|
||||
val segments = ArrayList<Segment>(wordList.size)
|
||||
wordList.forEach { word ->
|
||||
segments.add(wordIdentifier(word, images, videos, urls, emojis, tags))
|
||||
}
|
||||
|
||||
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
|
||||
// Scan for newline boundaries without splitting the string into a List<String>.
|
||||
while (lineStart <= content.length) {
|
||||
val nlIdx = content.indexOf('\n', lineStart)
|
||||
val lineEnd = if (nlIdx < 0) content.length else nlIdx
|
||||
paragraphs.add(parseParagraphLine(content, lineStart, lineEnd, images, videos, urls, emojis, tags))
|
||||
lineStart = lineEnd + 1
|
||||
}
|
||||
|
||||
val segmentsWithGalleries = GalleryParser().processParagraphs(paragraphSegments)
|
||||
return GalleryParser()
|
||||
.processParagraphs(paragraphs)
|
||||
.map { mergeIfAllRegular(it) }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
return segmentsWithGalleries
|
||||
.map { paragraph ->
|
||||
if (paragraph.words.isEmpty() || paragraph.words.any { it !is RegularTextSegment }) {
|
||||
paragraph
|
||||
} else {
|
||||
ParagraphState(
|
||||
persistentListOf<Segment>(RegularTextSegment(paragraph.words.joinToString(" ") { it.segmentText })),
|
||||
paragraph.isRTL,
|
||||
/**
|
||||
* Parses a single line of [content] (from [lineStart] to [lineEnd], exclusive) into a
|
||||
* [ParagraphState]. Trailing whitespace is ignored. For lines where every word is plain text,
|
||||
* a single [RegularTextSegment] is returned without allocating per-word substrings.
|
||||
*/
|
||||
private fun parseParagraphLine(
|
||||
content: String,
|
||||
lineStart: Int,
|
||||
lineEnd: Int,
|
||||
images: Set<String>,
|
||||
videos: Set<String>,
|
||||
urls: Set<String>,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): ParagraphState {
|
||||
val isRTL = isArabicInRange(content, lineStart, lineEnd)
|
||||
|
||||
// Strip trailing whitespace without creating a substring.
|
||||
var actualEnd = lineEnd
|
||||
while (actualEnd > lineStart && content[actualEnd - 1].isWhitespace()) actualEnd--
|
||||
|
||||
if (actualEnd <= lineStart) {
|
||||
// Empty or all-whitespace line.
|
||||
return ParagraphState(persistentListOf(RegularTextSegment("")), isRTL)
|
||||
}
|
||||
|
||||
// Fast path: if every word is plain text, return one segment for the whole line.
|
||||
if (isAllPlainText(content, lineStart, actualEnd, emojis)) {
|
||||
return ParagraphState(
|
||||
persistentListOf(RegularTextSegment(content.substring(lineStart, actualEnd))),
|
||||
isRTL,
|
||||
)
|
||||
}
|
||||
|
||||
// Mixed line: classify each space-delimited word individually.
|
||||
val segments = ArrayList<Segment>()
|
||||
var wordStart = lineStart
|
||||
while (wordStart < actualEnd) {
|
||||
val spIdx = content.indexOf(' ', wordStart)
|
||||
val wordEnd = if (spIdx < 0 || spIdx >= actualEnd) actualEnd else spIdx
|
||||
segments.add(wordIdentifier(content.substring(wordStart, wordEnd), images, videos, urls, emojis, tags))
|
||||
wordStart = wordEnd + 1
|
||||
}
|
||||
return ParagraphState(segments.toPersistentList(), isRTL)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when every space-delimited word in content[lineStart, lineEnd) is plain text
|
||||
* that needs no special rendering. A word is plain if [isPlainWord] returns true for it.
|
||||
*/
|
||||
private fun isAllPlainText(
|
||||
content: String,
|
||||
lineStart: Int,
|
||||
lineEnd: Int,
|
||||
emojis: Map<String, String>,
|
||||
): Boolean {
|
||||
var wordStart = lineStart
|
||||
while (wordStart < lineEnd) {
|
||||
val spIdx = content.indexOf(' ', wordStart)
|
||||
val wordEnd = if (spIdx < 0 || spIdx >= lineEnd) lineEnd else spIdx
|
||||
if (!isPlainWord(content, wordStart, wordEnd, emojis)) return false
|
||||
wordStart = wordEnd + 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* If [paragraph] contains only [RegularTextSegment] words, merges them back into a single
|
||||
* segment joined by spaces. This collapses paragraphs that were split word-by-word but turned
|
||||
* out to be entirely plain text (e.g. after gallery post-processing).
|
||||
*/
|
||||
private fun mergeIfAllRegular(paragraph: ParagraphState): ParagraphState {
|
||||
if (paragraph.words.size <= 1) return paragraph
|
||||
if (paragraph.words.any { it !is RegularTextSegment }) return paragraph
|
||||
return ParagraphState(
|
||||
persistentListOf(RegularTextSegment(paragraph.words.joinToString(" ") { it.segmentText })),
|
||||
paragraph.isRTL,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the word at content[wordStart, wordEnd) is definitely plain text and does
|
||||
* not need a full [wordIdentifier] classification. Conservative: returning false only means
|
||||
* the caller should run the full check; it never produces a wrong result.
|
||||
*/
|
||||
private fun isPlainWord(
|
||||
content: String,
|
||||
wordStart: Int,
|
||||
wordEnd: Int,
|
||||
emojis: Map<String, String>,
|
||||
): Boolean {
|
||||
val len = wordEnd - wordStart
|
||||
if (len == 0) return true
|
||||
|
||||
val c0 = content[wordStart]
|
||||
|
||||
// Quick first-character reject for token types that always start with a known character.
|
||||
when (c0) {
|
||||
'#' -> return false // hashtag (#hashtag) or tag reference (#[n])
|
||||
'@' -> return false // @npub… NIP-19 mention
|
||||
'd', 'D' -> if (len > 11 && content.startsWith("data:image/", wordStart)) return false
|
||||
'l', 'L' -> {
|
||||
if (len > 4 && content.startsWith("lnbc", wordStart, ignoreCase = true)) return false
|
||||
if (len > 5 && content.startsWith("lnurl", wordStart, ignoreCase = true)) return false
|
||||
}
|
||||
'c', 'C' -> {
|
||||
if (len > 6 &&
|
||||
(
|
||||
content.startsWith("cashuA", wordStart, ignoreCase = true) ||
|
||||
content.startsWith("cashuB", wordStart, ignoreCase = true)
|
||||
)
|
||||
) return false
|
||||
}
|
||||
'n', 'N' -> {
|
||||
// nostr: prefix or NIP-19 bech32 schemes (npub1, note1, naddr1, nevent1, nprofile1, nembed)
|
||||
if (len >= 5) {
|
||||
if (content.startsWith("nostr:", wordStart, ignoreCase = true)) return false
|
||||
if (wordStart + 1 < wordEnd) {
|
||||
when (content[wordStart + 1]) {
|
||||
'p', 'P' ->
|
||||
if (content.startsWith("npub1", wordStart, ignoreCase = true) ||
|
||||
content.startsWith("nprofile1", wordStart, ignoreCase = true)
|
||||
) return false
|
||||
'o', 'O' -> if (content.startsWith("note1", wordStart, ignoreCase = true)) return false
|
||||
'a', 'A' -> if (content.startsWith("naddr1", wordStart, ignoreCase = true)) return false
|
||||
'e', 'E' ->
|
||||
if (content.startsWith("nevent1", wordStart, ignoreCase = true) ||
|
||||
content.startsWith("nembed", wordStart, ignoreCase = true)
|
||||
) return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
'h', 'H' -> {
|
||||
// Only http(s):// words can be in the URL sets (parseValidUrls filters by HTTPRegex).
|
||||
if (len >= 7 &&
|
||||
(
|
||||
content.startsWith("http://", wordStart, ignoreCase = true) ||
|
||||
content.startsWith("https://", wordStart, ignoreCase = true)
|
||||
)
|
||||
) return false
|
||||
}
|
||||
}
|
||||
|
||||
// Single-pass character scan: detect markers that require full classification.
|
||||
var isPotentialPhone = len in 7..14
|
||||
var hasMidPeriod = false
|
||||
for (i in wordStart until wordEnd) {
|
||||
val c = content[i]
|
||||
val code = c.code
|
||||
when {
|
||||
c == ':' && emojis.isNotEmpty() -> return false // custom emoji :name: format
|
||||
c == '@' -> return false // email address
|
||||
c == '.' && i > wordStart && i < wordEnd - 1 -> hasMidPeriod = true // possible schemeless URL
|
||||
code in 0xFE00..0xFE0F -> return false // Unicode variation selectors (EmojiCoder)
|
||||
code == 0xDB40 -> return false // high surrogate for variation-selector supplement (EmojiCoder)
|
||||
isPotentialPhone && c !in '0'..'9' && c != '-' && c != ' ' && c != '.' -> isPotentialPhone = false
|
||||
}
|
||||
}
|
||||
|
||||
// Defer to wordIdentifier to confirm whether these are actually a phone / schemeless URL.
|
||||
if (isPotentialPhone) return false
|
||||
if (hasMidPeriod) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isArabicInRange(
|
||||
content: String,
|
||||
start: Int,
|
||||
end: Int,
|
||||
): Boolean {
|
||||
for (i in start until end) {
|
||||
val c = content[i]
|
||||
if (c in '\u0600'..'\u06FF' || c in '\u0750'..'\u077F') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isNumber(word: String) = numberPattern.matches(word)
|
||||
@@ -225,8 +391,6 @@ class RichTextParser {
|
||||
|
||||
fun isDate(word: String): Boolean = shortDatePattern.matches(word) || longDatePattern.matches(word)
|
||||
|
||||
private fun isArabic(text: String): Boolean = text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
|
||||
private fun wordIdentifier(
|
||||
word: String,
|
||||
images: Set<String>,
|
||||
@@ -237,58 +401,54 @@ class RichTextParser {
|
||||
): Segment {
|
||||
if (word.isEmpty()) return RegularTextSegment(word)
|
||||
|
||||
// Inline media and detected URL sets
|
||||
if (word.startsWith("data:image/")) {
|
||||
if (Patterns.BASE64_IMAGE.matches(word)) return Base64Segment(word)
|
||||
}
|
||||
|
||||
if (images.contains(word)) return ImageSegment(word)
|
||||
|
||||
if (videos.contains(word)) return VideoSegment(word)
|
||||
|
||||
if (urls.contains(word)) return LinkSegment(word)
|
||||
|
||||
// Custom emoji
|
||||
if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word)
|
||||
|
||||
// Lightning / Cashu payments
|
||||
if (word.startsWith("lnbc", true)) return InvoiceSegment(word)
|
||||
|
||||
if (word.startsWith("lnurl", true)) return WithdrawSegment(word)
|
||||
|
||||
if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word)
|
||||
|
||||
// Nostr / NIP-19 identifiers
|
||||
if (word.startsWith("#")) return parseHash(word, tags)
|
||||
|
||||
if (EmojiCoder.isCoded(word)) return SecretEmoji(word)
|
||||
if (startsWithNIP19Scheme(word)) return BechSegment(word)
|
||||
|
||||
// Contact info
|
||||
if (word.contains("@")) {
|
||||
if (Patterns.EMAIL_ADDRESS.matches(word)) return EmailSegment(word)
|
||||
}
|
||||
|
||||
if (startsWithNIP19Scheme(word)) return BechSegment(word)
|
||||
|
||||
if (isPotentialPhoneNumber(word) && !isDate(word)) {
|
||||
if (Patterns.PHONE.matches(word)) return PhoneSegment(word)
|
||||
}
|
||||
|
||||
val indexOfPeriod = word.indexOf(".")
|
||||
if (indexOfPeriod > 0 && indexOfPeriod < word.length - 1) { // periods cannot be the last one
|
||||
val schemelessMatcher = noProtocolUrlValidator.find(word)
|
||||
if (schemelessMatcher != null) {
|
||||
val url = schemelessMatcher.groups[1]?.value // url
|
||||
val additionalChars = schemelessMatcher.groups[4]?.value?.ifEmpty { null } // additional chars
|
||||
if (additionalUrlSchema.find(word) != null && url != null) {
|
||||
return SchemelessUrlSegment(word, url, additionalChars)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Schemeless URL fallback
|
||||
return parseSchemelessUrl(word) ?: RegularTextSegment(word)
|
||||
}
|
||||
|
||||
return RegularTextSegment(word)
|
||||
private fun parseSchemelessUrl(word: String): SchemelessUrlSegment? {
|
||||
val indexOfPeriod = word.indexOf('.')
|
||||
if (indexOfPeriod <= 0 || indexOfPeriod >= word.length - 1) return null
|
||||
val schemelessMatcher = noProtocolUrlValidator.find(word) ?: return null
|
||||
val url = schemelessMatcher.groups[1]?.value ?: return null
|
||||
val additionalChars = schemelessMatcher.groups[4]?.value?.ifEmpty { null }
|
||||
if (additionalUrlSchema.find(word) == null) return null
|
||||
return SchemelessUrlSegment(word, url, additionalChars)
|
||||
}
|
||||
|
||||
private fun parseHash(
|
||||
word: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Segment {
|
||||
// First #[n]
|
||||
// First #[n] — tag index reference
|
||||
try {
|
||||
val matcher = tagIndex.find(word)
|
||||
if (matcher != null) {
|
||||
@@ -312,7 +472,7 @@ class RichTextParser {
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
}
|
||||
|
||||
// Second #Amethyst
|
||||
// Second #Amethyst — plain hashtag
|
||||
try {
|
||||
val hashtagMatcher = hashTagsPattern.find(word)
|
||||
if (hashtagMatcher != null) {
|
||||
@@ -330,11 +490,21 @@ class RichTextParser {
|
||||
}
|
||||
|
||||
companion object {
|
||||
// --- Date / number patterns ---
|
||||
val longDatePattern: Regex = Regex("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$")
|
||||
val numberPattern: Regex = Regex("^(-?[\\d.]+)([a-zA-Z%]*)$")
|
||||
|
||||
// Android9 seems to have an issue starting this regex.
|
||||
// --- File extension lists ---
|
||||
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
|
||||
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
|
||||
|
||||
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
|
||||
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
|
||||
|
||||
// --- URL validation patterns ---
|
||||
|
||||
// Android 9 seems to have an issue starting this regex.
|
||||
val noProtocolUrlValidator =
|
||||
try {
|
||||
Regex(
|
||||
@@ -354,23 +524,20 @@ class RichTextParser {
|
||||
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
|
||||
.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
|
||||
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
|
||||
|
||||
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
|
||||
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
|
||||
|
||||
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
|
||||
val hashTagsPattern: Regex =
|
||||
Regex("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", RegexOption.IGNORE_CASE)
|
||||
|
||||
// --- Nostr / NIP-19 ---
|
||||
val acceptedNIP19schemes =
|
||||
listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1", "nembed") +
|
||||
listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1", "nembed").map {
|
||||
it.uppercase()
|
||||
}
|
||||
|
||||
private fun removeQueryParamsForExtensionComparison(fullUrl: String): String =
|
||||
// --- Hash tag / tag index ---
|
||||
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
|
||||
val hashTagsPattern: Regex =
|
||||
Regex("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", RegexOption.IGNORE_CASE)
|
||||
|
||||
// Strips query string and fragment from a URL so the bare path extension can be compared.
|
||||
private fun stripUrlForExtensionCheck(fullUrl: String): String =
|
||||
if (fullUrl.contains("?")) {
|
||||
fullUrl.split("?")[0]
|
||||
} else if (fullUrl.contains("#")) {
|
||||
@@ -380,20 +547,19 @@ class RichTextParser {
|
||||
}
|
||||
|
||||
fun isImageOrVideoUrl(url: String): Boolean {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
|
||||
return imageExtensions.any { removedParamsFromUrl.endsWith(it) } ||
|
||||
videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val bare = stripUrlForExtensionCheck(url)
|
||||
return imageExtensions.any { bare.endsWith(it) } ||
|
||||
videoExtensions.any { bare.endsWith(it) }
|
||||
}
|
||||
|
||||
fun isImageUrl(url: String): Boolean {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
return imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val bare = stripUrlForExtensionCheck(url)
|
||||
return imageExtensions.any { bare.endsWith(it) }
|
||||
}
|
||||
|
||||
fun isVideoUrl(url: String): Boolean {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val bare = stripUrlForExtensionCheck(url)
|
||||
return videoExtensions.any { bare.endsWith(it) }
|
||||
}
|
||||
|
||||
fun isValidURL(url: String?): Boolean =
|
||||
@@ -407,9 +573,9 @@ class RichTextParser {
|
||||
}
|
||||
|
||||
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val bare = stripUrlForExtensionCheck(fullUrl)
|
||||
val isImage = imageExtensions.any { bare.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { bare.endsWith(it) }
|
||||
|
||||
return if (isImage) {
|
||||
MediaUrlImage(fullUrl)
|
||||
|
||||
Reference in New Issue
Block a user