New RichText engine to help with testing classes.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
|||||||
|
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.actions.ImmutableListOfLists
|
||||||
|
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.startsWithNIP19Scheme
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||||
|
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 java.util.regex.Pattern
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class RichTextViewerState(
|
||||||
|
val urlSet: ImmutableSet<String>,
|
||||||
|
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
|
||||||
|
val imageList: ImmutableList<ZoomableUrlContent>,
|
||||||
|
val customEmoji: ImmutableMap<String, String>,
|
||||||
|
val paragraphs: ImmutableList<ImmutableList<Segment>>
|
||||||
|
)
|
||||||
|
|
||||||
|
object CachedRichTextParser {
|
||||||
|
val richTextCache = LruCache<String, RichTextViewerState>(200)
|
||||||
|
|
||||||
|
fun parseText(content: String, tags: ImmutableListOfLists<String>): RichTextViewerState {
|
||||||
|
return if (richTextCache[content] != null) {
|
||||||
|
richTextCache[content]
|
||||||
|
} else {
|
||||||
|
val newUrls = RichTextParser().parseText(content, tags)
|
||||||
|
richTextCache.put(content, newUrls)
|
||||||
|
newUrls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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%]*)$")
|
||||||
|
|
||||||
|
// Group 1 = url, group 4 additional chars
|
||||||
|
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||||
|
|
||||||
|
class RichTextParser() {
|
||||||
|
fun parseText(
|
||||||
|
content: String,
|
||||||
|
tags: ImmutableListOfLists<String>
|
||||||
|
): 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 {
|
||||||
|
it.originalUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||||
|
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||||
|
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||||
|
ZoomableUrlImage(fullUrl)
|
||||||
|
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||||
|
ZoomableUrlVideo(fullUrl)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.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<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ImmutableList<Segment>> {
|
||||||
|
var paragraphSegments = persistentListOf<ImmutableList<Segment>>()
|
||||||
|
|
||||||
|
content.split('\n').forEach { paragraph ->
|
||||||
|
var segments = persistentListOf<Segment>()
|
||||||
|
var isDirty = false
|
||||||
|
|
||||||
|
val wordList = paragraph.split(' ')
|
||||||
|
wordList.forEach { word ->
|
||||||
|
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
|
||||||
|
if (wordSegment !is RegularTextSegment) {
|
||||||
|
isDirty = true
|
||||||
|
}
|
||||||
|
segments = segments.add(wordSegment)
|
||||||
|
}
|
||||||
|
|
||||||
|
val newSegments = if (isDirty) {
|
||||||
|
if (isArabic(paragraph)) {
|
||||||
|
segments.asReversed().toImmutableList()
|
||||||
|
} else {
|
||||||
|
segments
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
persistentListOf<Segment>(RegularTextSegment(paragraph))
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraphSegments = paragraphSegments.add(newSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paragraphSegments
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): 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 (schemelessMatcher.find()) {
|
||||||
|
val url = schemelessMatcher.group(1) // url
|
||||||
|
val additionalChars = schemelessMatcher.group(4) // additional chars
|
||||||
|
if (url != null) {
|
||||||
|
SchemelessUrlSegment(word, url, additionalChars)
|
||||||
|
} else {
|
||||||
|
RegularTextSegment(word)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RegularTextSegment(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHash(word: String, tags: ImmutableListOfLists<String>): 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) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RegularTextSegment(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.R
|
|||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||||
import com.vitorpamplona.amethyst.ui.components.*
|
import com.vitorpamplona.amethyst.ui.components.*
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import com.vitorpamplona.amethyst.service.model.AddressableEvent
|
|||||||
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
|
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.text.ClickableText
|
|||||||
import androidx.compose.material.LocalTextStyle
|
import androidx.compose.material.LocalTextStyle
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ fun ClickableEmail(email: String) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
ClickableText(
|
ClickableText(
|
||||||
text = AnnotatedString("$email "),
|
text = remember { AnnotatedString(email) },
|
||||||
onClick = { runCatching { context.sendMail(email) } },
|
onClick = { runCatching { context.sendMail(email) } },
|
||||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.text.ClickableText
|
|||||||
import androidx.compose.material.LocalTextStyle
|
import androidx.compose.material.LocalTextStyle
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ fun ClickablePhone(phone: String) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
ClickableText(
|
ClickableText(
|
||||||
text = AnnotatedString("$phone "),
|
text = remember { AnnotatedString(phone) },
|
||||||
onClick = { runCatching { context.dial(phone) } },
|
onClick = { runCatching { context.dial(phone) } },
|
||||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
|
|||||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.ImmutableMap
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
@@ -111,7 +112,16 @@ private fun DisplayEvent(
|
|||||||
nav: (String) -> Unit
|
nav: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
LoadNote(nip19.hex) {
|
LoadNote(nip19.hex) {
|
||||||
|
if (it != null) {
|
||||||
DisplayNoteLink(it, nip19, nav)
|
DisplayNoteLink(it, nip19, nav)
|
||||||
|
} else {
|
||||||
|
CreateClickableText(
|
||||||
|
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
|
||||||
|
suffix = nip19.additionalChars,
|
||||||
|
route = remember(nip19) { "Event/${nip19.hex}" },
|
||||||
|
nav = nav
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +131,16 @@ private fun DisplayNote(
|
|||||||
nav: (String) -> Unit
|
nav: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
LoadNote(nip19.hex) {
|
LoadNote(nip19.hex) {
|
||||||
|
if (it != null) {
|
||||||
DisplayNoteLink(it, nip19, nav)
|
DisplayNoteLink(it, nip19, nav)
|
||||||
|
} else {
|
||||||
|
CreateClickableText(
|
||||||
|
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
|
||||||
|
suffix = nip19.additionalChars,
|
||||||
|
route = remember(nip19) { "Event/${nip19.hex}" },
|
||||||
|
nav = nav
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +305,7 @@ private fun RenderUserAsClickableText(
|
|||||||
@Composable
|
@Composable
|
||||||
fun CreateClickableText(
|
fun CreateClickableText(
|
||||||
clickablePart: String,
|
clickablePart: String,
|
||||||
suffix: String,
|
suffix: String?,
|
||||||
maxLines: Int = Int.MAX_VALUE,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
overrideColor: Color? = null,
|
overrideColor: Color? = null,
|
||||||
fontWeight: FontWeight = FontWeight.Normal,
|
fontWeight: FontWeight = FontWeight.Normal,
|
||||||
@@ -310,11 +329,13 @@ fun CreateClickableText(
|
|||||||
withStyle(clickablePartStyle) {
|
withStyle(clickablePartStyle) {
|
||||||
append(clickablePart)
|
append(clickablePart)
|
||||||
}
|
}
|
||||||
|
if (!suffix.isNullOrBlank()) {
|
||||||
withStyle(nonClickablePartStyle) {
|
withStyle(nonClickablePartStyle) {
|
||||||
append(suffix)
|
append(suffix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ClickableText(
|
ClickableText(
|
||||||
text = text,
|
text = text,
|
||||||
@@ -488,7 +509,7 @@ data class DoubleEmojiList(
|
|||||||
@Composable
|
@Composable
|
||||||
fun CreateClickableTextWithEmoji(
|
fun CreateClickableTextWithEmoji(
|
||||||
clickablePart: String,
|
clickablePart: String,
|
||||||
suffix: String,
|
suffix: String?,
|
||||||
maxLines: Int = Int.MAX_VALUE,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
overrideColor: Color? = null,
|
overrideColor: Color? = null,
|
||||||
fontWeight: FontWeight = FontWeight.Normal,
|
fontWeight: FontWeight = FontWeight.Normal,
|
||||||
@@ -507,7 +528,7 @@ fun CreateClickableTextWithEmoji(
|
|||||||
|
|
||||||
if (emojis.isNotEmpty()) {
|
if (emojis.isNotEmpty()) {
|
||||||
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
|
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
|
||||||
val newEmojiList2 = assembleAnnotatedList(suffix, emojis)
|
val newEmojiList2 = suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList<Renderable>()
|
||||||
|
|
||||||
if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) {
|
if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) {
|
||||||
emojiLists = DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())
|
emojiLists = DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ fun MayBeWithdrawal(lnurlWord: String) {
|
|||||||
ClickableWithdrawal(withdrawalString = it)
|
ClickableWithdrawal(withdrawalString = it)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = "$lnurlWord ",
|
text = lnurlWord,
|
||||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
|
|||||||
InvoicePreview(it.first, it.second)
|
InvoicePreview(it.first, it.second)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = "$lnbcWord ",
|
text = lnbcWord,
|
||||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.components
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.LruCache
|
|
||||||
import android.util.Patterns
|
import android.util.Patterns
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.foundation.text.ClickableText
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.foundation.text.InlineTextContent
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
import androidx.compose.foundation.text.appendInlineContent
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.LocalContentAlpha
|
||||||
|
import androidx.compose.material.LocalContentColor
|
||||||
import androidx.compose.material.LocalTextStyle
|
import androidx.compose.material.LocalTextStyle
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.ProvideTextStyle
|
import androidx.compose.material.ProvideTextStyle
|
||||||
@@ -17,25 +19,47 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.takeOrElse
|
||||||
|
import androidx.compose.ui.layout.SubcomposeLayout
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.style.TextDirection
|
import androidx.compose.ui.text.style.TextDirection
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.lifecycle.distinctUntilChanged
|
import androidx.lifecycle.distinctUntilChanged
|
||||||
import androidx.lifecycle.map
|
import androidx.lifecycle.map
|
||||||
import com.halilibo.richtext.markdown.Markdown
|
import com.halilibo.richtext.markdown.Markdown
|
||||||
import com.halilibo.richtext.markdown.MarkdownParseOptions
|
import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||||
import com.linkedin.urls.detection.UrlDetector
|
|
||||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
|
||||||
import com.vitorpamplona.amethyst.model.HashtagIcon
|
import com.vitorpamplona.amethyst.model.HashtagIcon
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
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.RichTextViewerState
|
||||||
|
import com.vitorpamplona.amethyst.service.SchemelessUrlSegment
|
||||||
|
import com.vitorpamplona.amethyst.service.Segment
|
||||||
|
import com.vitorpamplona.amethyst.service.WithdrawSegment
|
||||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||||
@@ -43,15 +67,6 @@ import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.collections.immutable.ImmutableMap
|
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.collections.immutable.persistentMapOf
|
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
|
||||||
import kotlinx.collections.immutable.toImmutableSet
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.net.MalformedURLException
|
import java.net.MalformedURLException
|
||||||
@@ -62,14 +77,8 @@ import java.util.regex.Pattern
|
|||||||
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
|
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
|
||||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
|
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
|
||||||
|
|
||||||
// Group 1 = url, group 4 additional chars
|
|
||||||
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
|
||||||
|
|
||||||
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
|
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
|
||||||
|
|
||||||
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
|
|
||||||
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
|
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||||
val urlPattern: Pattern = Patterns.WEB_URL
|
|
||||||
|
|
||||||
fun isValidURL(url: String?): Boolean {
|
fun isValidURL(url: String?): Boolean {
|
||||||
return try {
|
return try {
|
||||||
@@ -110,16 +119,7 @@ fun RichTextViewer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val urlSetCache = LruCache<String, RichTextViewerState>(200)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
|
||||||
@Immutable
|
|
||||||
data class RichTextViewerState(
|
|
||||||
val urlSet: ImmutableSet<String>,
|
|
||||||
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
|
|
||||||
val imageList: ImmutableList<ZoomableUrlContent>,
|
|
||||||
val customEmoji: ImmutableMap<String, String>
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderRegular(
|
private fun RenderRegular(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -130,221 +130,128 @@ private fun RenderRegular(
|
|||||||
nav: (String) -> Unit
|
nav: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val state by remember(content) {
|
val state by remember(content) {
|
||||||
if (urlSetCache[content] != null) {
|
mutableStateOf(CachedRichTextParser.parseText(content, tags))
|
||||||
mutableStateOf(urlSetCache[content])
|
|
||||||
} else {
|
|
||||||
val newUrls = parseUrls(content, tags)
|
|
||||||
urlSetCache.put(content, newUrls)
|
|
||||||
mutableStateOf(newUrls)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val paragraphs = remember(content) {
|
val currentTextStyle = LocalTextStyle.current
|
||||||
content.split('\n').toImmutableList()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
val textStyle = currentTextStyle.copy(
|
||||||
|
textDirection = TextDirection.Content,
|
||||||
|
lineHeight = 1.4.em,
|
||||||
|
color = currentTextStyle.color.takeOrElse {
|
||||||
|
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
MeasureSpaceWidth() { spaceWidth ->
|
||||||
|
Column {
|
||||||
|
if (canPreview) {
|
||||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||||
paragraphs.forEach { paragraph ->
|
state.paragraphs.forEach { paragraph ->
|
||||||
RenderParagraph(paragraph, state, canPreview, backgroundColor, accountViewModel, nav, tags)
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceWidth)) {
|
||||||
}
|
paragraph.forEach { word ->
|
||||||
}
|
RenderWordWithPreview(
|
||||||
|
|
||||||
@Composable
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
private fun RenderParagraph(
|
|
||||||
paragraph: String,
|
|
||||||
state: RichTextViewerState,
|
|
||||||
canPreview: Boolean,
|
|
||||||
backgroundColor: MutableState<Color>,
|
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
nav: (String) -> Unit,
|
|
||||||
tags: ImmutableListOfLists<String>
|
|
||||||
) {
|
|
||||||
val s = remember(paragraph) {
|
|
||||||
if (isArabic(paragraph)) {
|
|
||||||
paragraph.split(' ').reversed().toImmutableList()
|
|
||||||
} else {
|
|
||||||
paragraph.split(' ').toImmutableList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FlowRow() {
|
|
||||||
s.forEach { word: String ->
|
|
||||||
RenderWord(
|
|
||||||
word,
|
word,
|
||||||
state,
|
state,
|
||||||
canPreview,
|
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
textStyle,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav
|
||||||
tags
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseUrls(
|
|
||||||
content: String,
|
|
||||||
tags: ImmutableListOfLists<String>
|
|
||||||
): RichTextViewerState {
|
|
||||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
|
||||||
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
|
||||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
|
||||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
|
||||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
|
||||||
ZoomableUrlImage(fullUrl)
|
|
||||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
|
||||||
ZoomableUrlVideo(fullUrl)
|
|
||||||
} else {
|
} else {
|
||||||
null
|
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||||
}
|
state.paragraphs.forEach { paragraph ->
|
||||||
}.associateBy { it.url }
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceWidth)) {
|
||||||
val imageList = imagesForPager.values.toList()
|
paragraph.forEach { word ->
|
||||||
|
RenderWordWithoutPreview(
|
||||||
val emojiMap =
|
word,
|
||||||
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
|
state,
|
||||||
|
backgroundColor,
|
||||||
return if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
|
textStyle,
|
||||||
RichTextViewerState(
|
accountViewModel,
|
||||||
urlSet.toImmutableSet(),
|
nav
|
||||||
imagesForPager.toImmutableMap(),
|
|
||||||
imageList.toImmutableList(),
|
|
||||||
emojiMap.toImmutableMap()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
RichTextViewerState(
|
|
||||||
persistentSetOf(),
|
|
||||||
persistentMapOf(),
|
|
||||||
persistentListOf(),
|
|
||||||
persistentMapOf()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
enum class WordType {
|
}
|
||||||
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, CASHU, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderWord(
|
fun MeasureSpaceWidth(
|
||||||
word: String,
|
content: @Composable (measuredWidth: Dp) -> Unit
|
||||||
state: RichTextViewerState,
|
|
||||||
canPreview: Boolean,
|
|
||||||
backgroundColor: MutableState<Color>,
|
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
nav: (String) -> Unit,
|
|
||||||
tags: ImmutableListOfLists<String>
|
|
||||||
) {
|
) {
|
||||||
val type = remember(word) {
|
SubcomposeLayout { constraints ->
|
||||||
if (word == "") {
|
val measuredWidth = subcompose("viewToMeasure", { Text(" ") })[0].measure(Constraints()).width.toDp()
|
||||||
WordType.OTHER
|
|
||||||
}
|
|
||||||
if (state.imagesForPager[word] != null) {
|
|
||||||
WordType.IMAGE
|
|
||||||
} else if (state.urlSet.contains(word)) {
|
|
||||||
WordType.LINK
|
|
||||||
} else if (state.customEmoji.any { word.contains(it.key) }) {
|
|
||||||
WordType.EMOJI
|
|
||||||
} else if (word.startsWith("lnbc", true)) {
|
|
||||||
WordType.INVOICE
|
|
||||||
} else if (word.startsWith("lnurl", true)) {
|
|
||||||
WordType.WITHDRAW
|
|
||||||
} else if (word.startsWith("cashuA", true)) {
|
|
||||||
WordType.CASHU
|
|
||||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
|
||||||
WordType.EMAIL
|
|
||||||
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
|
|
||||||
WordType.PHONE
|
|
||||||
} else if (startsWithNIP19Scheme(word)) {
|
|
||||||
WordType.BECH
|
|
||||||
} else if (word.startsWith("#")) {
|
|
||||||
if (tagIndex.matcher(word).matches()) {
|
|
||||||
if (tags.lists.isNotEmpty()) {
|
|
||||||
WordType.HASH_INDEX
|
|
||||||
} else {
|
|
||||||
WordType.OTHER
|
|
||||||
}
|
|
||||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
|
||||||
WordType.HASHTAG
|
|
||||||
} else {
|
|
||||||
WordType.OTHER
|
|
||||||
}
|
|
||||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
|
||||||
WordType.SCHEMELESS_URL
|
|
||||||
} else {
|
|
||||||
WordType.OTHER
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canPreview) {
|
val contentPlaceable = subcompose("content") {
|
||||||
RenderWordWithPreview(type, word, state, tags, backgroundColor, accountViewModel, nav)
|
content(measuredWidth)
|
||||||
} else {
|
}[0].measure(constraints)
|
||||||
RenderWordWithoutPreview(type, word, state, tags, backgroundColor, accountViewModel, nav)
|
layout(contentPlaceable.width, contentPlaceable.height) {
|
||||||
|
contentPlaceable.place(0, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderWordWithoutPreview(
|
private fun RenderWordWithoutPreview(
|
||||||
type: WordType,
|
word: Segment,
|
||||||
word: String,
|
|
||||||
state: RichTextViewerState,
|
state: RichTextViewerState,
|
||||||
tags: ImmutableListOfLists<String>,
|
|
||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
|
style: TextStyle,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: (String) -> Unit
|
nav: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val wordSpace = remember(word) {
|
when (word) {
|
||||||
"$word "
|
|
||||||
}
|
|
||||||
|
|
||||||
when (type) {
|
|
||||||
// Don't preview Images
|
// Don't preview Images
|
||||||
WordType.IMAGE -> ClickableUrl(wordSpace, word)
|
is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||||
WordType.LINK -> ClickableUrl(wordSpace, word)
|
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||||
WordType.EMOJI -> RenderCustomEmoji(word, state)
|
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||||
// Don't offer to pay invoices
|
// Don't offer to pay invoices
|
||||||
WordType.INVOICE -> NormalWord(wordSpace)
|
is InvoiceSegment -> NormalWord(word.segmentText, style)
|
||||||
// Don't offer to withdraw
|
// Don't offer to withdraw
|
||||||
WordType.WITHDRAW -> NormalWord(wordSpace)
|
is WithdrawSegment -> NormalWord(word.segmentText, style)
|
||||||
WordType.CASHU -> NormalWord(wordSpace)
|
is CashuSegment -> NormalWord(word.segmentText, style)
|
||||||
WordType.EMAIL -> ClickableEmail(word)
|
is EmailSegment -> ClickableEmail(word.segmentText)
|
||||||
WordType.PHONE -> ClickablePhone(word)
|
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||||
WordType.BECH -> BechLink(word, false, backgroundColor, accountViewModel, nav)
|
is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav)
|
||||||
WordType.HASHTAG -> HashTag(word, nav)
|
is HashTagSegment -> HashTag(word, nav)
|
||||||
WordType.HASH_INDEX -> TagLink(word, tags, false, backgroundColor, accountViewModel, nav)
|
is HashIndexUserSegment -> TagLink(word, nav)
|
||||||
WordType.SCHEMELESS_URL -> NoProtocolUrlRenderer(word)
|
is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav)
|
||||||
WordType.OTHER -> NormalWord(wordSpace)
|
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||||
|
is RegularTextSegment -> NormalWord(word.segmentText, style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderWordWithPreview(
|
private fun RenderWordWithPreview(
|
||||||
type: WordType,
|
word: Segment,
|
||||||
word: String,
|
|
||||||
state: RichTextViewerState,
|
state: RichTextViewerState,
|
||||||
tags: ImmutableListOfLists<String>,
|
|
||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
|
style: TextStyle,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: (String) -> Unit
|
nav: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val wordSpace = remember(word) {
|
when (word) {
|
||||||
"$word "
|
is ImageSegment -> ZoomableContentView(word.segmentText, state)
|
||||||
}
|
is LinkSegment -> UrlPreview(word.segmentText, word.segmentText)
|
||||||
|
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||||
when (type) {
|
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
|
||||||
WordType.IMAGE -> ZoomableContentView(word, state)
|
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
|
||||||
WordType.LINK -> UrlPreview(word, wordSpace)
|
is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
|
||||||
WordType.EMOJI -> RenderCustomEmoji(word, state)
|
is EmailSegment -> ClickableEmail(word.segmentText)
|
||||||
WordType.INVOICE -> MayBeInvoicePreview(word)
|
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||||
WordType.WITHDRAW -> MayBeWithdrawal(word)
|
is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
|
||||||
WordType.CASHU -> CashuPreview(word, accountViewModel)
|
is HashTagSegment -> HashTag(word, nav)
|
||||||
WordType.EMAIL -> ClickableEmail(word)
|
is HashIndexUserSegment -> TagLink(word, nav)
|
||||||
WordType.PHONE -> ClickablePhone(word)
|
is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav)
|
||||||
WordType.BECH -> BechLink(word, true, backgroundColor, accountViewModel, nav)
|
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
|
||||||
WordType.HASHTAG -> HashTag(word, nav)
|
is RegularTextSegment -> NormalWord(word.segmentText, style)
|
||||||
WordType.HASH_INDEX -> TagLink(word, tags, true, backgroundColor, accountViewModel, nav)
|
|
||||||
WordType.SCHEMELESS_URL -> NoProtocolUrlRenderer(word)
|
|
||||||
WordType.OTHER -> NormalWord(wordSpace)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,64 +263,30 @@ private fun ZoomableContentView(word: String, state: RichTextViewerState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NormalWord(word: String) {
|
private fun NormalWord(word: String, style: TextStyle) {
|
||||||
Text(
|
BasicText(
|
||||||
text = word,
|
text = word,
|
||||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
style = style
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
|
||||||
data class UrlWithExtraChars(val url: String, val extraChars: String)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NoProtocolUrlRenderer(word: String) {
|
private fun NoProtocolUrlRenderer(word: SchemelessUrlSegment) {
|
||||||
val wordSpace = remember(word) {
|
RenderUrl(word)
|
||||||
"$word "
|
|
||||||
}
|
|
||||||
|
|
||||||
var linkedUrl by remember(word) {
|
|
||||||
mutableStateOf<UrlWithExtraChars?>(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(key1 = word) {
|
|
||||||
launch(Dispatchers.Default) {
|
|
||||||
val matcher = noProtocolUrlValidator.matcher(word)
|
|
||||||
if (matcher.find()) {
|
|
||||||
val url = matcher.group(1) // url
|
|
||||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
linkedUrl = UrlWithExtraChars(url, additionalChars)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Crossfade(targetState = linkedUrl) {
|
|
||||||
if (it != null) {
|
|
||||||
RenderUrl(it)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = wordSpace,
|
|
||||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderUrl(it: UrlWithExtraChars) {
|
private fun RenderUrl(segment: SchemelessUrlSegment) {
|
||||||
Row() {
|
Row() {
|
||||||
ClickableUrl(it.url, "https://${it.url}")
|
ClickableUrl(segment.url, "https://${segment.url}")
|
||||||
Text("${it.extraChars} ")
|
segment.extras?.let { it1 -> Text(it1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
|
fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
|
||||||
CreateTextWithEmoji(
|
CreateTextWithEmoji(
|
||||||
text = remember { "$word " },
|
text = word,
|
||||||
emojis = state.customEmoji
|
emojis = state.customEmoji
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -705,10 +578,6 @@ private fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableLis
|
|||||||
return returnContent
|
return returnContent
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isArabic(text: String): Boolean {
|
|
||||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startsWithNIP19Scheme(word: String): Boolean {
|
fun startsWithNIP19Scheme(word: String): Boolean {
|
||||||
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
||||||
|
|
||||||
@@ -722,6 +591,7 @@ data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return)
|
|||||||
fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||||
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
|
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
|
||||||
|
|
||||||
|
if (loadedLink == null) {
|
||||||
LaunchedEffect(key1 = word) {
|
LaunchedEffect(key1 = word) {
|
||||||
launch(Dispatchers.IO) {
|
launch(Dispatchers.IO) {
|
||||||
Nip19.uriToRoute(word)?.let {
|
Nip19.uriToRoute(word)?.let {
|
||||||
@@ -740,29 +610,34 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Crossfade(targetState = loadedLink) {
|
|
||||||
if (canPreview && it?.baseNote != null) {
|
|
||||||
Row() {
|
|
||||||
DisplayFullNote(it.baseNote, accountViewModel, backgroundColor, nav, it)
|
|
||||||
}
|
}
|
||||||
} else if (it?.nip19 != null) {
|
|
||||||
|
if (canPreview && loadedLink?.baseNote != null) {
|
||||||
Row() {
|
Row() {
|
||||||
ClickableRoute(it.nip19, nav)
|
DisplayFullNote(
|
||||||
|
loadedLink?.baseNote!!,
|
||||||
|
accountViewModel,
|
||||||
|
backgroundColor,
|
||||||
|
nav,
|
||||||
|
loadedLink!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (loadedLink?.nip19 != null) {
|
||||||
|
Row() {
|
||||||
|
ClickableRoute(loadedLink?.nip19!!, nav)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val text = remember {
|
val text = remember {
|
||||||
if (word.length > 16) {
|
if (word.length > 16) {
|
||||||
word.replaceRange(8, word.length - 8, ":")
|
word.replaceRange(8, word.length - 8, ":")
|
||||||
} else {
|
} else {
|
||||||
"$word "
|
word
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(text = text, maxLines = 1)
|
Text(text = text, maxLines = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DisplayFullNote(
|
private fun DisplayFullNote(
|
||||||
@@ -782,11 +657,7 @@ private fun DisplayFullNote(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val extraChars = remember(loadedLink) {
|
val extraChars = remember(loadedLink) {
|
||||||
if (loadedLink.nip19.additionalChars.isNotBlank()) {
|
loadedLink.nip19.additionalChars.ifBlank { null }
|
||||||
"${loadedLink.nip19.additionalChars} "
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extraChars?.let {
|
extraChars?.let {
|
||||||
@@ -796,61 +667,29 @@ private fun DisplayFullNote(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class HashWordWithExtra(val hashtag: String, val extras: String?)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HashTag(word: String, nav: (String) -> Unit) {
|
fun HashTag(word: HashTagSegment, nav: (String) -> Unit) {
|
||||||
var tagSuffixPair by remember { mutableStateOf<HashWordWithExtra?>(null) }
|
|
||||||
|
|
||||||
if (tagSuffixPair == null) {
|
|
||||||
LaunchedEffect(key1 = word) {
|
|
||||||
launch(Dispatchers.IO) {
|
|
||||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
|
||||||
|
|
||||||
val (myTag, mySuffix) = try {
|
|
||||||
hashtagMatcher.find()
|
|
||||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
|
||||||
Pair(null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (myTag != null) {
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
tagSuffixPair = HashWordWithExtra(myTag, mySuffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Crossfade(targetState = tagSuffixPair) {
|
|
||||||
if (it != null) {
|
|
||||||
Row() {
|
Row() {
|
||||||
RenderHashtag(it, nav)
|
RenderHashtag(word, nav)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(text = remember { "$word " })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderHashtag(
|
private fun RenderHashtag(
|
||||||
tagPair: HashWordWithExtra,
|
segment: HashTagSegment,
|
||||||
nav: (String) -> Unit
|
nav: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val primary = MaterialTheme.colors.primary
|
val primary = MaterialTheme.colors.primary
|
||||||
val hashtagIcon = remember(tagPair.hashtag) { checkForHashtagWithIcon(tagPair.hashtag, primary) }
|
val hashtagIcon = remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) }
|
||||||
ClickableText(
|
ClickableText(
|
||||||
text = buildAnnotatedString {
|
text = buildAnnotatedString {
|
||||||
withStyle(
|
withStyle(
|
||||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||||
) {
|
) {
|
||||||
append("#${tagPair.hashtag}")
|
append("#${segment.hashtag}")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClick = { nav("Hashtag/${tagPair.hashtag}") }
|
onClick = { nav("Hashtag/${segment.hashtag}") }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hashtagIcon != null) {
|
if (hashtagIcon != null) {
|
||||||
@@ -871,8 +710,8 @@ private fun RenderHashtag(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
tagPair.extras?.ifBlank { "" }?.let {
|
segment.extras?.ifBlank { "" }?.let {
|
||||||
Text(text = "$it ")
|
Text(text = it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,63 +732,46 @@ private fun InlineIcon(hashtagIcon: HashtagIcon) =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LoadedTag(val user: User?, val note: Note?, val addedChars: String)
|
@Composable
|
||||||
|
fun TagLink(word: HashIndexUserSegment, nav: (String) -> Unit) {
|
||||||
|
LoadUser(baseUserHex = word.hex) {
|
||||||
|
if (it == null) {
|
||||||
|
Text(text = word.segmentText)
|
||||||
|
} else {
|
||||||
|
Row() {
|
||||||
|
DisplayUserFromTag(it, word.extras, nav)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
fun LoadNote(baseNoteHex: String, content: @Composable (Note?) -> Unit) {
|
||||||
var loadedTag by remember { mutableStateOf<LoadedTag?>(null) }
|
var note by remember(baseNoteHex) {
|
||||||
|
mutableStateOf<Note?>(LocalCache.getNoteIfExists(baseNoteHex))
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(key1 = word) {
|
if (note == null) {
|
||||||
if (loadedTag == null) {
|
LaunchedEffect(key1 = baseNoteHex) {
|
||||||
launch(Dispatchers.IO) {
|
launch(Dispatchers.IO) {
|
||||||
val matcher = tagIndex.matcher(word)
|
note = LocalCache.checkGetOrCreateNote(baseNoteHex)
|
||||||
val (index, suffix) = try {
|
|
||||||
matcher.find()
|
|
||||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
|
||||||
Pair(null, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
|
||||||
val tag = tags.lists[index]
|
|
||||||
|
|
||||||
if (tag.size > 1) {
|
|
||||||
if (tag[0] == "p") {
|
|
||||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
loadedTag = LoadedTag(it, null, suffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
|
||||||
LocalCache.checkGetOrCreateNote(tag[1])?.let {
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
loadedTag = LoadedTag(null, it, suffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Crossfade(targetState = loadedTag) {
|
content(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TagLink(word: HashIndexEventSegment, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||||
|
LoadNote(baseNoteHex = word.hex) {
|
||||||
if (it == null) {
|
if (it == null) {
|
||||||
Text(
|
Text(text = remember { word.segmentText.toShortenHex() })
|
||||||
text = remember {
|
} else {
|
||||||
"$word "
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (it.user != null) {
|
|
||||||
Row() {
|
|
||||||
DisplayUserFromTag(it.user, it.addedChars ?: "", nav)
|
|
||||||
}
|
|
||||||
} else if (it.note != null) {
|
|
||||||
Row() {
|
Row() {
|
||||||
DisplayNoteFromTag(
|
DisplayNoteFromTag(
|
||||||
it.note,
|
it,
|
||||||
it.addedChars ?: "",
|
word.extras,
|
||||||
canPreview,
|
canPreview,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
@@ -963,7 +785,7 @@ fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolea
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DisplayNoteFromTag(
|
private fun DisplayNoteFromTag(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
addedChars: String,
|
addedChars: String?,
|
||||||
canPreview: Boolean,
|
canPreview: Boolean,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
@@ -978,23 +800,22 @@ private fun DisplayNoteFromTag(
|
|||||||
isQuotedNote = true,
|
isQuotedNote = true,
|
||||||
nav = nav
|
nav = nav
|
||||||
)
|
)
|
||||||
addedChars.ifBlank { null }?.let {
|
|
||||||
Text(text = remember { "$it " })
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ClickableNoteTag(baseNote, nav)
|
ClickableNoteTag(baseNote, nav)
|
||||||
Text(text = remember { "$addedChars " })
|
}
|
||||||
|
|
||||||
|
addedChars?.ifBlank { null }?.let {
|
||||||
|
Text(text = it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DisplayUserFromTag(
|
private fun DisplayUserFromTag(
|
||||||
baseUser: User,
|
baseUser: User,
|
||||||
addedChars: String,
|
addedChars: String?,
|
||||||
nav: (String) -> Unit
|
nav: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val route = remember { "User/${baseUser.pubkeyHex}" }
|
val route = remember { "User/${baseUser.pubkeyHex}" }
|
||||||
val suffix = remember { "$addedChars " }
|
|
||||||
val hex = remember { baseUser.pubkeyDisplayHex() }
|
val hex = remember { baseUser.pubkeyDisplayHex() }
|
||||||
|
|
||||||
val meta by baseUser.live().metadata.map {
|
val meta by baseUser.live().metadata.map {
|
||||||
@@ -1008,7 +829,7 @@ private fun DisplayUserFromTag(
|
|||||||
}
|
}
|
||||||
CreateClickableTextWithEmoji(
|
CreateClickableTextWithEmoji(
|
||||||
clickablePart = displayName,
|
clickablePart = displayName,
|
||||||
suffix = suffix,
|
suffix = addedChars,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
route = route,
|
route = route,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
|
|||||||
Reference in New Issue
Block a user