New RichText engine to help with testing classes.

This commit is contained in:
Vitor Pamplona
2023-07-01 11:51:46 -04:00
parent 57db7f0272
commit d179c93c0b
10 changed files with 4653 additions and 407 deletions
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,