Migrates rich text parser from jvm to commons
This commit is contained in:
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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.richtext
|
||||
|
||||
class ExpandableTextCutOffCalculator {
|
||||
companion object {
|
||||
private const val SHORT_TEXT_LENGTH = 350
|
||||
private const val SHORTEN_AFTER_LINES = 10
|
||||
private const val TOO_FAR_SEARCH_THE_OTHER_WAY = 450
|
||||
|
||||
fun indexToCutOff(content: String): Int {
|
||||
// Cuts the text in the first space or first new line after SHORT_TEXT_LENGTH characters
|
||||
val firstSpaceAfterCut =
|
||||
content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val firstNewLineAfterCut =
|
||||
content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
|
||||
// Cuts the text if too many new lines have passed.
|
||||
val firstLineAfterLineLimits =
|
||||
content.nthIndexOf('\n', SHORTEN_AFTER_LINES).let { if (it < 0) content.length else it }
|
||||
|
||||
// gets the minimum of them all.
|
||||
val min = minOf(firstSpaceAfterCut, firstNewLineAfterCut, firstLineAfterLineLimits)
|
||||
|
||||
val result =
|
||||
if (min > TOO_FAR_SEARCH_THE_OTHER_WAY) {
|
||||
// if it is still too big, finds the first space or new line BEFORE the cut off.
|
||||
val newString = content.take(SHORT_TEXT_LENGTH)
|
||||
val firstSpaceBeforeCut =
|
||||
newString.lastIndexOf(' ').let { if (it < 0) content.length else it }
|
||||
val firstNewLineBeforeCut =
|
||||
newString.lastIndexOf('\n').let { if (it < 0) content.length else it }
|
||||
|
||||
maxOf(firstSpaceBeforeCut, firstNewLineBeforeCut)
|
||||
} else {
|
||||
min
|
||||
}
|
||||
|
||||
// Only returns if the difference between short and long posts is more than 100 chars or too many new lines.
|
||||
return if (result == firstLineAfterLineLimits || result + 100 < content.length) {
|
||||
result
|
||||
} else {
|
||||
content.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.nthIndexOf(
|
||||
ch: Char,
|
||||
N: Int,
|
||||
): Int {
|
||||
var occur = N
|
||||
var pos = -1
|
||||
|
||||
while (occur > 0) {
|
||||
// calling the native function multiple times is faster than looping just once
|
||||
pos = indexOf(ch, pos + 1)
|
||||
if (pos == -1) return -1
|
||||
occur--
|
||||
}
|
||||
|
||||
return if (occur == 0) pos else -1
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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.richtext
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
data class ParagraphImageAnalysis(
|
||||
val imageCount: Int,
|
||||
val isImageOnly: Boolean,
|
||||
val hasMultipleImages: Boolean,
|
||||
)
|
||||
|
||||
class GalleryParser {
|
||||
fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis {
|
||||
var imageCount = 0
|
||||
var hasNonWhitespaceNonImageContent = false
|
||||
|
||||
paragraph.words.forEach { word ->
|
||||
when (word) {
|
||||
is ImageSegment, is Base64Segment -> imageCount++
|
||||
is VideoSegment -> hasNonWhitespaceNonImageContent = true // Videos are not images
|
||||
is RegularTextSegment -> {
|
||||
if (word.segmentText.isNotBlank()) {
|
||||
hasNonWhitespaceNonImageContent = true
|
||||
}
|
||||
}
|
||||
else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc.
|
||||
}
|
||||
}
|
||||
|
||||
val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent
|
||||
val hasMultipleImages = imageCount > 1
|
||||
|
||||
return ParagraphImageAnalysis(
|
||||
imageCount = imageCount,
|
||||
isImageOnly = isImageOnly,
|
||||
hasMultipleImages = hasMultipleImages,
|
||||
)
|
||||
}
|
||||
|
||||
fun collectConsecutiveImageParagraphs(
|
||||
paragraphs: List<ParagraphState>,
|
||||
startIndex: Int,
|
||||
): Pair<List<ParagraphState>, Int> {
|
||||
val imageParagraphs = mutableListOf<ParagraphState>()
|
||||
var j = startIndex
|
||||
|
||||
while (j < paragraphs.size) {
|
||||
val currentParagraph = paragraphs[j]
|
||||
val words = currentParagraph.words
|
||||
|
||||
// Fast path for empty check
|
||||
if (words.isEmpty()) {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for single whitespace word
|
||||
if (words.size == 1) {
|
||||
val firstWord = words.first()
|
||||
if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an image-only paragraph using unified analysis
|
||||
val analysis = analyzeParagraphImages(currentParagraph)
|
||||
if (analysis.isImageOnly) {
|
||||
imageParagraphs.add(currentParagraph)
|
||||
j++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return imageParagraphs to j
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun processParagraphs(paragraphs: List<ParagraphState>): List<ParagraphState> {
|
||||
val result = mutableListOf<ParagraphState>()
|
||||
|
||||
var paragraphIndex = 0
|
||||
while (paragraphIndex < paragraphs.size) {
|
||||
val paragraph = paragraphs[paragraphIndex]
|
||||
|
||||
if (paragraph.words.isEmpty()) {
|
||||
// Empty paragraph - render normally with FlowRow (will render nothing)
|
||||
result.add(paragraph)
|
||||
paragraphIndex++
|
||||
} else {
|
||||
val analysis = analyzeParagraphImages(paragraph)
|
||||
if (analysis.isImageOnly) {
|
||||
// Collect consecutive image-only paragraphs for gallery
|
||||
val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex)
|
||||
val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList()
|
||||
|
||||
if (allImageWords.size > 1) {
|
||||
result.add(ImageGalleryParagraph(allImageWords, paragraph.isRTL))
|
||||
} else {
|
||||
// Single image - render with FlowRow wrapper
|
||||
result.add(paragraph)
|
||||
}
|
||||
|
||||
paragraphIndex = endIndex // Return next index to process
|
||||
} else if (analysis.hasMultipleImages) {
|
||||
// Mixed paragraph with multiple images - break it down into many paragraphs
|
||||
result.addAll(processWordsWithImageGrouping(paragraph))
|
||||
paragraphIndex++
|
||||
} else {
|
||||
result.add(paragraph)
|
||||
paragraphIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun processWordsWithImageGrouping(paragraph: ParagraphState): List<ParagraphState> {
|
||||
val resultingParagraphs = mutableListOf<ParagraphState>()
|
||||
var i = 0
|
||||
val n = paragraph.words.size
|
||||
|
||||
var currentParagraphSegments = mutableListOf<Segment>()
|
||||
while (i < n) {
|
||||
val word = paragraph.words[i]
|
||||
|
||||
if (word is ImageSegment || word is Base64Segment) {
|
||||
// Collect consecutive image/whitespace segments (but not videos)
|
||||
val imageSegments = mutableListOf<Segment>()
|
||||
var j = i
|
||||
var hasVideo = false
|
||||
|
||||
while (j < n) {
|
||||
val seg = paragraph.words[j]
|
||||
when {
|
||||
seg is VideoSegment -> {
|
||||
hasVideo = true
|
||||
break
|
||||
}
|
||||
seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg)
|
||||
seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ }
|
||||
else -> break
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
// If we found a video, don't create a gallery - render images individually
|
||||
if (hasVideo || imageSegments.size <= 1) {
|
||||
currentParagraphSegments.addAll(imageSegments)
|
||||
} else {
|
||||
if (currentParagraphSegments.isNotEmpty()) {
|
||||
resultingParagraphs.add(ParagraphState(currentParagraphSegments.toImmutableList(), paragraph.isRTL))
|
||||
currentParagraphSegments = mutableListOf<Segment>()
|
||||
}
|
||||
|
||||
resultingParagraphs.add(ImageGalleryParagraph(imageSegments.toImmutableList(), paragraph.isRTL))
|
||||
}
|
||||
|
||||
i = j // jump past processed run
|
||||
} else {
|
||||
currentParagraphSegments.add(word)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if (currentParagraphSegments.isNotEmpty()) {
|
||||
resultingParagraphs.add(ParagraphState(currentParagraphSegments.toImmutableList(), paragraph.isRTL))
|
||||
}
|
||||
|
||||
return resultingParagraphs
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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.richtext
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import java.io.File
|
||||
|
||||
@Immutable
|
||||
abstract class BaseMediaContent(
|
||||
val description: String? = null,
|
||||
val dim: DimensionTag? = null,
|
||||
val blurhash: String? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
abstract class MediaUrlContent(
|
||||
val url: String,
|
||||
description: String? = null,
|
||||
val hash: String? = null,
|
||||
dim: DimensionTag? = null,
|
||||
blurhash: String? = null,
|
||||
val uri: String? = null,
|
||||
val mimeType: String? = null,
|
||||
) : BaseMediaContent(description, dim, blurhash)
|
||||
|
||||
@Immutable
|
||||
open class MediaUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
blurhash: String? = null,
|
||||
dim: DimensionTag? = null,
|
||||
uri: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
mimeType: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||
|
||||
class EncryptedMediaUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
blurhash: String? = null,
|
||||
dim: DimensionTag? = null,
|
||||
uri: String? = null,
|
||||
contentWarning: String? = null,
|
||||
mimeType: String? = null,
|
||||
val encryptionAlgo: String,
|
||||
val encryptionKey: ByteArray,
|
||||
val encryptionNonce: ByteArray,
|
||||
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
|
||||
|
||||
@Immutable
|
||||
open class MediaUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
dim: DimensionTag? = null,
|
||||
uri: String? = null,
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null,
|
||||
blurhash: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
mimeType: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||
|
||||
@Immutable
|
||||
class EncryptedMediaUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
dim: DimensionTag? = null,
|
||||
uri: String? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
blurhash: String? = null,
|
||||
contentWarning: String? = null,
|
||||
mimeType: String? = null,
|
||||
val encryptionAlgo: String,
|
||||
val encryptionKey: ByteArray,
|
||||
val encryptionNonce: ByteArray,
|
||||
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType)
|
||||
|
||||
@Immutable
|
||||
abstract class MediaPreloadedContent(
|
||||
val localFile: File?,
|
||||
description: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val isVerified: Boolean? = null,
|
||||
dim: DimensionTag? = null,
|
||||
blurhash: String? = null,
|
||||
val uri: String,
|
||||
val id: String? = null,
|
||||
) : BaseMediaContent(description, dim, blurhash) {
|
||||
fun localFileExists() = localFile != null && localFile.exists()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class MediaLocalImage(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
dim: DimensionTag? = null,
|
||||
blurhash: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String,
|
||||
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri)
|
||||
|
||||
@Immutable
|
||||
class MediaLocalVideo(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
dim: DimensionTag? = null,
|
||||
blurhash: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String,
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null,
|
||||
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri)
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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.richtext
|
||||
|
||||
/**
|
||||
* Pattern constants for email and phone validation.
|
||||
* These replace android.util.Patterns for KMP compatibility.
|
||||
*/
|
||||
object Patterns {
|
||||
/**
|
||||
* Email address pattern from RFC 5322... From android.util.Patterns.
|
||||
*/
|
||||
val EMAIL_ADDRESS: Regex =
|
||||
Regex(
|
||||
"[a-zA-Z0-9+._%-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+",
|
||||
)
|
||||
|
||||
/**
|
||||
* Phone number pattern - matches common phone formats.
|
||||
*/
|
||||
val PHONE: Regex =
|
||||
Regex(
|
||||
"^[+]?[(]?[0-9]{1,4}[)]?[-\\s./0-9]*\$",
|
||||
)
|
||||
|
||||
val BASE64_IMAGE: Regex =
|
||||
Regex(
|
||||
"data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|")});base64,([a-zA-Z0-9+/]+={0,2})",
|
||||
)
|
||||
}
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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.richtext
|
||||
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
|
||||
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
|
||||
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
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 kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.text.iterator
|
||||
|
||||
class RichTextParser {
|
||||
fun createMediaContent(
|
||||
fullUrl: String,
|
||||
eventTags: Map<String, IMetaTag>,
|
||||
description: String?,
|
||||
callbackUri: String? = null,
|
||||
): MediaUrlContent? {
|
||||
val frags = Nip54InlineMetadata().parse(fullUrl)
|
||||
|
||||
val tags = eventTags.get(fullUrl)?.properties ?: emptyMap()
|
||||
|
||||
val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull()
|
||||
|
||||
val isImage: Boolean
|
||||
val isVideo: Boolean
|
||||
|
||||
if (contentType != null) {
|
||||
isImage = contentType.startsWith("image/")
|
||||
isVideo = contentType.startsWith("video/")
|
||||
} else if (fullUrl.startsWith("data:")) {
|
||||
isImage = fullUrl.startsWith("data:image/")
|
||||
isVideo = fullUrl.startsWith("data:video/")
|
||||
} else {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
return if (isImage) {
|
||||
MediaUrlImage(
|
||||
url = fullUrl,
|
||||
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
|
||||
hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(),
|
||||
blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(),
|
||||
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
|
||||
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
|
||||
uri = callbackUri,
|
||||
mimeType = contentType,
|
||||
)
|
||||
} else if (isVideo) {
|
||||
MediaUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
|
||||
hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(),
|
||||
blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(),
|
||||
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
|
||||
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
|
||||
uri = callbackUri,
|
||||
mimeType = contentType,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun parseValidUrls(content: String): LinkedHashSet<String> {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
|
||||
return urls.mapNotNullTo(LinkedHashSet(urls.size)) {
|
||||
if (it.originalUrl.contains("@")) {
|
||||
if (Patterns.EMAIL_ADDRESS.matches(it.originalUrl)) {
|
||||
null
|
||||
} else {
|
||||
it.originalUrl
|
||||
}
|
||||
} else if (isNumber(it.originalUrl)) {
|
||||
null // avoids urls that look like 123.22
|
||||
} else if (it.originalUrl.contains("。")) {
|
||||
null // avoids Japanese characters as fake urls
|
||||
} else {
|
||||
if (HTTPRegex.matches(it.originalUrl)) {
|
||||
it.originalUrl
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String?,
|
||||
): RichTextViewerState {
|
||||
val imetas = tags.lists.imetasByUrl()
|
||||
val urlSet = parseValidUrls(content)
|
||||
|
||||
val imagesForPager =
|
||||
urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, imetas, content, callbackUri) }.associateBy { it.url }
|
||||
|
||||
val imageUrls = imagesForPager.filterValues { it is MediaUrlImage }.keys
|
||||
val videoUrls = imagesForPager.filterValues { it is MediaUrlVideo }.keys
|
||||
|
||||
val emojiMap = CustomEmoji.createEmojiMap(tags)
|
||||
|
||||
val segments = findTextSegments(content, imageUrls, videoUrls, urlSet, emojiMap, tags)
|
||||
|
||||
val base64Images = segments.map { it.words.filterIsInstance<Base64Segment>() }.flatten()
|
||||
|
||||
val imagesForPagerWithBase64 =
|
||||
imagesForPager +
|
||||
base64Images
|
||||
.mapNotNull { createMediaContent(it.segmentText, emptyMap(), content, callbackUri) }
|
||||
.associateBy { it.url }
|
||||
|
||||
return RichTextViewerState(
|
||||
urlSet.toImmutableSet(),
|
||||
imagesForPagerWithBase64.toImmutableMap(),
|
||||
imagesForPagerWithBase64.values.toImmutableList(),
|
||||
emojiMap.toImmutableMap(),
|
||||
segments,
|
||||
tags,
|
||||
)
|
||||
}
|
||||
|
||||
private fun findTextSegments(
|
||||
content: String,
|
||||
images: Set<String>,
|
||||
videos: Set<String>,
|
||||
urls: Set<String>,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): ImmutableList<ParagraphState> {
|
||||
val lines = content.split('\n')
|
||||
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
val segmentsWithGalleries = GalleryParser().processParagraphs(paragraphSegments)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
private fun isNumber(word: String) = numberPattern.matches(word)
|
||||
|
||||
private fun isPhoneNumberChar(c: Char): Boolean =
|
||||
when (c) {
|
||||
in '0'..'9' -> true
|
||||
'-' -> true
|
||||
' ' -> true
|
||||
'.' -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun isPotentialPhoneNumber(word: String): Boolean {
|
||||
if (word.length !in 7..14) return false
|
||||
var isPotentialNumber = true
|
||||
|
||||
for (c in word) {
|
||||
if (!isPhoneNumberChar(c)) {
|
||||
isPotentialNumber = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return isPotentialNumber
|
||||
}
|
||||
|
||||
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>,
|
||||
videos: Set<String>,
|
||||
urls: Set<String>,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Segment {
|
||||
if (word.isEmpty()) return RegularTextSegment(word)
|
||||
|
||||
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)
|
||||
|
||||
if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word)
|
||||
|
||||
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)
|
||||
|
||||
if (word.startsWith("#")) return parseHash(word, tags)
|
||||
|
||||
if (EmojiCoder.isCoded(word)) return SecretEmoji(word)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RegularTextSegment(word)
|
||||
}
|
||||
|
||||
private fun parseHash(
|
||||
word: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Segment {
|
||||
// First #[n]
|
||||
try {
|
||||
val matcher = tagIndex.find(word)
|
||||
if (matcher != null) {
|
||||
val index = matcher.groups[1]?.value?.toInt()
|
||||
val suffix = matcher.groups[2]?.value
|
||||
|
||||
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
|
||||
try {
|
||||
val hashtagMatcher = hashTagsPattern.find(word)
|
||||
if (hashtagMatcher != null) {
|
||||
val hashtag = hashtagMatcher.groups[1]?.value
|
||||
if (hashtag != null) {
|
||||
return HashTagSegment(word, hashtag, hashtagMatcher.groups[2]?.value?.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: 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.
|
||||
val noProtocolUrlValidator =
|
||||
try {
|
||||
Regex(
|
||||
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Regex(
|
||||
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)",
|
||||
)
|
||||
}
|
||||
|
||||
val additionalUrlSchema =
|
||||
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
|
||||
.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
val HTTPRegex =
|
||||
"^((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)
|
||||
|
||||
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 =
|
||||
if (fullUrl.contains("?")) {
|
||||
fullUrl.split("?")[0]
|
||||
} else if (fullUrl.contains("#")) {
|
||||
fullUrl.split("#")[0]
|
||||
} else {
|
||||
fullUrl
|
||||
}
|
||||
|
||||
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 =
|
||||
try {
|
||||
URL(url).toURI()
|
||||
true
|
||||
} catch (e: MalformedURLException) {
|
||||
false
|
||||
} catch (e: URISyntaxException) {
|
||||
false
|
||||
}
|
||||
|
||||
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
|
||||
return if (isImage) {
|
||||
MediaUrlImage(fullUrl)
|
||||
} else if (isVideo) {
|
||||
MediaUrlVideo(fullUrl)
|
||||
} else {
|
||||
MediaUrlImage(fullUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun startsWithNIP19Scheme(word: String): Boolean {
|
||||
if (word.isEmpty()) return false
|
||||
return if (word[0] == 'n' || word[0] == 'N') {
|
||||
if (word.startsWith("nostr:n") || word.startsWith("NOSTR:N")) {
|
||||
acceptedNIP19schemes.any { word.startsWith(it, 6) }
|
||||
} else {
|
||||
acceptedNIP19schemes.any { word.startsWith(it) }
|
||||
}
|
||||
} else if (word[0] == '@') {
|
||||
acceptedNIP19schemes.any { word.startsWith(it, 1) }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isUrlWithoutScheme(url: String) = noProtocolUrlValidator.matches(url)
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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.richtext
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
@Immutable
|
||||
class RichTextViewerState(
|
||||
val urlSet: ImmutableSet<String>,
|
||||
val imagesForPager: ImmutableMap<String, MediaUrlContent>,
|
||||
val imageList: ImmutableList<MediaUrlContent>,
|
||||
val customEmoji: ImmutableMap<String, String>,
|
||||
val paragraphs: ImmutableList<ParagraphState>,
|
||||
val tags: ImmutableListOfLists<String>,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
open class ParagraphState(
|
||||
val words: ImmutableList<Segment>,
|
||||
val isRTL: Boolean,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
class ImageGalleryParagraph(
|
||||
words: ImmutableList<Segment>,
|
||||
isRTL: Boolean,
|
||||
) : ParagraphState(words, isRTL)
|
||||
|
||||
@Immutable
|
||||
open class Segment(
|
||||
val segmentText: String,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
class ImageSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class VideoSegment(
|
||||
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)
|
||||
|
||||
class SecretEmoji(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PhoneSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class BechSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class Base64Segment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user