Support for NIP-30

This commit is contained in:
Vitor Pamplona 2023-05-15 21:26:59 -04:00
parent 95ad33ace2
commit fc56f3988e
13 changed files with 557 additions and 114 deletions

View File

@ -0,0 +1,32 @@
package com.vitorpamplona.amethyst.service
import java.util.regex.Pattern
class NIP30Parser {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {
val matcher = customEmojiPattern.matcher(input)
val list = mutableListOf<String>()
while (matcher.find()) {
list.add(matcher.group())
}
if (list.isEmpty()) {
return listOf(input)
}
val regularChars = input.split(customEmojiPattern.toRegex())
var finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
if (index < list.size) {
finalList.add(list[index])
}
index++
}
return finalList
}
}

View File

@ -380,8 +380,9 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
onClick(myUser)
}
) {
Text(
"${myUser.toBestDisplayName()}",
CreateTextWithEmoji(
text = "${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
color = Color.White,
textAlign = TextAlign.Center
)

View File

@ -1,6 +1,14 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@ -11,12 +19,31 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
@ -64,28 +91,28 @@ private fun DisplayEvent(
if (note.event is ChannelCreateEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"${nip19.additionalChars} ",
"Channel/${nip19.hex}",
navController
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"${nip19.additionalChars} ",
"Room/${note.author?.pubkeyHex}",
navController
)
} else if (channel != null) {
CreateClickableText(
channel.toBestDisplayName(),
nip19.additionalChars,
"${nip19.additionalChars} ",
"Channel/${note.channel()?.idHex}",
navController
)
} else {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"${nip19.additionalChars} ",
"Event/${nip19.hex}",
navController
)
@ -114,34 +141,34 @@ private fun DisplayNote(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val note = remember(noteState) { noteState?.note } ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"${nip19.additionalChars} ",
"Channel/${nip19.hex}",
navController
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"${nip19.additionalChars} ",
"Room/${note.author?.pubkeyHex}",
navController
)
} else if (channel != null) {
CreateClickableText(
channel.toBestDisplayName(),
nip19.additionalChars,
"${nip19.additionalChars} ",
"Channel/${note.channel()?.idHex}",
navController
)
} else {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"${nip19.additionalChars} ",
"Note/${nip19.hex}",
navController
)
@ -174,7 +201,7 @@ private fun DisplayAddress(
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"${nip19.additionalChars} ",
"Note/${nip19.hex}",
navController
)
@ -202,14 +229,19 @@ private fun DisplayUser(
userBase?.let {
val userState by it.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = remember { "User/${it.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
CreateClickableText(
user.toBestDisplayName(),
nip19.additionalChars,
"User/${nip19.hex}",
navController
)
if (userDisplayName != null) {
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = "${nip19.additionalChars} ",
tags = userTags,
route = route,
navController = navController
)
}
}
if (userBase == null) {
@ -231,9 +263,264 @@ fun CreateClickableText(clickablePart: String, suffix: String, route: String, na
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
) {
append("$suffix ")
append(suffix)
}
},
onClick = { navController.navigate(route) }
)
}
@Composable
fun CreateTextWithEmoji(
text: String,
tags: List<List<String>>?,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
) {
val emojis = remember {
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
}
CreateTextWithEmoji(text, emojis, color, textAlign, fontWeight, fontSize, maxLines, overflow, modifier)
}
@Composable
fun CreateTextWithEmoji(
text: String,
emojis: Map<String, String>,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
) {
val textColor = color.takeOrElse {
LocalTextStyle.current.color.takeOrElse {
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
}
}
if (emojis.isEmpty()) {
Text(
text = text,
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize,
maxLines = maxLines,
overflow = overflow,
modifier = modifier
)
} else {
val myList = remember {
assembleAnnotatedList(text, emojis)
}
val style = LocalTextStyle.current.merge(
TextStyle(
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize
)
).toSpanStyle()
InLineIconRenderer(myList, style, maxLines, overflow, modifier)
}
}
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
tags: List<List<String>>?,
style: TextStyle,
onClick: (Int) -> Unit
) {
val emojis = remember(tags) {
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
}
if (emojis.isEmpty()) {
ClickableText(
AnnotatedString(clickablePart),
style = style,
onClick = onClick
)
} else {
val myList = remember {
assembleAnnotatedList(clickablePart, emojis)
}
ClickableInLineIconRenderer(myList, style.toSpanStyle()) {
onClick(it)
}
}
}
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String,
tags: List<List<String>>?,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
navController: NavController
) {
val emojis = remember(tags) {
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
}
if (emojis.isEmpty()) {
CreateClickableText(clickablePart, suffix, route, navController)
} else {
val myList = remember {
assembleAnnotatedList(clickablePart, emojis)
}
ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
navController.navigate(route)
}
val myList2 = remember {
assembleAnnotatedList(suffix, emojis)
}
InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
}
}
fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Renderable> {
return NIP30Parser().buildArray(text).map {
val url = emojis[it]
if (url != null) {
ImageUrlType(url)
} else {
TextType(it)
}
}
}
open class Renderable()
class TextType(val text: String) : Renderable()
class ImageUrlType(val url: String) : Renderable()
@Composable
fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
Pair(
"inlineContent$idx",
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp),
colorFilter = ColorFilter.tint(style.color)
)
}
)
} else {
null
}
}.associate { it.first to it.second }
val annotatedText = buildAnnotatedString {
wordsInOrder.forEachIndexed { idx, value ->
withStyle(
style
) {
if (value is TextType) {
append(value.text)
} else if (value is ImageUrlType) {
appendInlineContent("inlineContent$idx", "[icon]")
}
}
}
}
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult ->
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}
BasicText(
text = annotatedText,
modifier = pressIndicator,
inlineContent = inlineContent,
onTextLayout = {
layoutResult.value = it
}
)
}
@Composable
fun InLineIconRenderer(
wordsInOrder: List<Renderable>,
style: SpanStyle,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
) {
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
Pair(
"inlineContent$idx",
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp),
colorFilter = ColorFilter.tint(style.color)
)
}
)
} else {
null
}
}.associate { it.first to it.second }
val annotatedText = buildAnnotatedString {
wordsInOrder.forEachIndexed { idx, value ->
withStyle(
style
) {
if (value is TextType) {
append(value.text)
} else if (value is ImageUrlType) {
appendInlineContent("inlineContent$idx", "[icon]")
}
}
}
}
Text(
text = annotatedText,
inlineContent = inlineContent,
maxLines = maxLines,
overflow = overflow,
modifier = modifier
)
}

View File

@ -109,7 +109,8 @@ class RichTextViewerState(
val content: String,
val urlSet: Set<String>,
val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent>
val imageList: List<ZoomableUrlContent>,
val customEmoji: Map<String, String>
)
@Composable
@ -122,7 +123,7 @@ private fun RenderRegular(
navController: NavController
) {
var processedState by remember {
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList()))
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
}
val scope = rememberCoroutineScope()
@ -143,8 +144,10 @@ private fun RenderRegular(
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
if (urlSet.isNotEmpty()) {
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
val emojiMap = tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList, emojiMap)
}
}
}
@ -167,6 +170,8 @@ private fun RenderRegular(
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
@ -235,6 +240,8 @@ private fun RenderRegular(
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
@ -293,6 +300,14 @@ private fun RenderRegular(
}
}
@Composable
fun RenderCustomEmoji(word: String, customEmoji: Map<String, String>) {
CreateTextWithEmoji(
text = "$word ",
emojis = customEmoji
)
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
val myMarkDownStyle = richTextDefaults.copy(
@ -600,8 +615,10 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
matcher.find()
@ -630,8 +647,24 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
}
baseUserPair?.let {
ClickableUserTag(it.first, navController)
Text(text = "${it.second} ")
val innerUserState by it.first.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${it.first.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = "${it.second} ",
tags = userTags,
route = route,
navController = navController
)
}
baseNotePair?.let {

View File

@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@ -149,7 +150,10 @@ fun AccountSwitchBottomSheet(
val npubShortHex = acc.npub.toShortenHex()
user.bestDisplayName()?.let {
Text(it)
CreateTextWithEmoji(
text = it,
tags = user.info?.latestMetadata?.tags
)
}
Text(npubShortHex)

View File

@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
@ -168,8 +169,9 @@ fun ProfileContent(
})
)
if (accountUser.bestDisplayName() != null) {
Text(
accountUser.bestDisplayName() ?: "",
CreateTextWithEmoji(
text = accountUser.bestDisplayName() ?: "",
tags = accountUser.info?.latestMetadata?.tags,
modifier = Modifier
.padding(top = 7.dp)
.clickable(onClick = {
@ -185,19 +187,22 @@ fun ProfileContent(
)
}
if (accountUser.bestUsername() != null) {
Text(
" @${accountUser.bestUsername()}",
CreateTextWithEmoji(
text = " @${accountUser.bestUsername()}",
tags = accountUser.info?.latestMetadata?.tags,
color = Color.LightGray,
modifier = Modifier
.padding(top = 15.dp)
.clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
.clickable(
onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
)
}
Row(

View File

@ -58,6 +58,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@ -218,14 +220,13 @@ fun ChatroomMessageCompose(
})
)
Text(
" ${author.toBestDisplayName()}",
CreateClickableTextWithEmoji(
clickablePart = " ${author.toBestDisplayName()}",
suffix = "",
tags = author.info?.latestMetadata?.tags,
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable(onClick = {
author.let {
navController.navigate("User/${it.pubkeyHex}")
}
})
route = "User/${author.pubkeyHex}",
navController = navController
)
}
}
@ -251,33 +252,39 @@ fun ChatroomMessageCompose(
val event = note.event
if (event is ChannelCreateEvent) {
val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + (
channelInfo.name
?: ""
) + " ${stringResource(R.string.with_description_of)} '" + (
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture)} '" + (
channelInfo.picture
?: ""
) + "'"
val text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + (
channelInfo.name
?: ""
) + " ${stringResource(R.string.with_description_of)} '" + (
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture)} '" + (
channelInfo.picture
?: ""
) + "'"
CreateTextWithEmoji(
text = text,
tags = note.author?.info?.latestMetadata?.tags
)
} else if (event is ChannelMetadataEvent) {
val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
channelInfo.name
?: ""
) + "$', {stringResource(R.string.description_to)} '" + (
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture_to)} '" + (
channelInfo.picture
?: ""
) + "'"
val text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
channelInfo.name
?: ""
) + "', ${stringResource(R.string.description_to)} '" + (
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture_to)} '" + (
channelInfo.picture
?: ""
) + "'"
CreateTextWithEmoji(
text = text,
tags = note.author?.info?.latestMetadata?.tags
)
} else {
val eventContent = accountViewModel.decrypt(note)

View File

@ -109,7 +109,7 @@ import com.vitorpamplona.amethyst.service.model.ReportedKey
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.CreateClickableText
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
@ -1245,13 +1245,15 @@ fun DisplayHighlight(
val userState by userBase.live().metadata.observeAsState()
val route = remember { "User/${userBase.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
if (userDisplayName != null) {
CreateClickableText(
userDisplayName,
"",
route,
navController
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = "",
tags = userTags,
route = route,
navController = navController
)
}
}

View File

@ -18,6 +18,7 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -59,8 +60,9 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
ClickableText(
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"),
CreateClickableTextWithEmoji(
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
)
@ -196,8 +198,9 @@ fun ReplyInformationChannel(
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
ClickableText(
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"),
CreateClickableTextWithEmoji(
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
)

View File

@ -5,34 +5,31 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = remember(noteState) { noteState?.note?.author } ?: return
val author = note.author
if (author != null) {
UsernameDisplay(author, weight)
}
UsernameDisplay(author, weight)
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val bestUserName = remember(userState) { userState?.user?.bestUsername() }
val bestDisplayName = remember(userState) { userState?.user?.bestDisplayName() }
val npubDisplay = remember { baseUser.pubkeyDisplayHex() }
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
val bestUserName = user.bestUsername()
val bestDisplayName = user.bestDisplayName()
val npubDisplay = user.pubkeyDisplayHex()
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, weight)
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
}
@Composable
@ -40,31 +37,36 @@ private fun UserNameDisplay(
bestUserName: String?,
bestDisplayName: String?,
npubDisplay: String,
tags: List<List<String>>?,
modifier: Modifier
) {
if (bestUserName != null && bestDisplayName != null) {
Text(
bestDisplayName,
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold
)
Text(
"@$bestUserName",
CreateTextWithEmoji(
text = "@$bestUserName",
tags = tags,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
} else if (bestDisplayName != null) {
Text(
bestDisplayName,
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
} else if (bestUserName != null) {
Text(
"@$bestUserName",
CreateTextWithEmoji(
text = "@$bestUserName",
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View File

@ -34,6 +34,7 @@ import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner
@ -83,10 +84,10 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 5.dp),
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
CreateTextWithEmoji(
text = user.bestDisplayName() ?: user.bestUsername() ?: "",
tags = user.info?.latestMetadata?.tags,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)

View File

@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.ResizeImage
@ -470,29 +471,36 @@ private fun ProfileActions(
private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
val userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
Row(verticalAlignment = Alignment.Bottom) {
user.bestDisplayName()?.let {
Text(
it,
modifier = Modifier.padding(top = 7.dp),
(user.bestDisplayName() ?: user.bestUsername())?.let {
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
CreateTextWithEmoji(
text = it,
tags = tags,
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (user.bestDisplayName() != null) {
user.bestUsername()?.let {
Text(
"@$it",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp)
)
) {
CreateTextWithEmoji(
text = "@$it",
tags = tags,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}

View File

@ -0,0 +1,58 @@
package com.vitorpamplona.amethyst.service
import junit.framework.TestCase.assertEquals
import org.junit.Test
class Nip30Test {
@Test()
fun parseEmoji() {
val input = "Alex Gleason :soapbox:"
assertEquals(
listOf("Alex Gleason ", ":soapbox:", ""),
NIP30Parser().buildArray(input)
)
}
@Test()
fun parseEmojiInverted() {
val input = ":soapbox:Alex Gleason"
assertEquals(
listOf("", ":soapbox:", "Alex Gleason"),
NIP30Parser().buildArray(input)
)
}
@Test()
fun parseEmoji2() {
val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo"
assertEquals(
listOf("Hello ", ":gleasonator:", " 😂 ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"),
NIP30Parser().buildArray(input)
)
println(NIP30Parser().buildArray(input).joinToString(","))
}
@Test()
fun parseEmoji3() {
val input = "hello vitor: how can I help:"
assertEquals(
listOf("hello vitor: how can I help:"),
NIP30Parser().buildArray(input)
)
}
@Test()
fun parseJapanese() {
val input = "\uD883\uDEDE\uD883\uDEDE麺の:x30EDE:。:\uD883\uDEDE:(Violation of NIP-30)"
assertEquals(
listOf("\uD883\uDEDE\uD883\uDEDE麺の", ":x30EDE:", "。:\uD883\uDEDE:(Violation of NIP-30)"),
NIP30Parser().buildArray(input)
)
}
}