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
@@ -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
}
}
@@ -380,8 +380,9 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
onClick(myUser) onClick(myUser)
} }
) { ) {
Text( CreateTextWithEmoji(
"${myUser.toBestDisplayName()}", text = "${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
color = Color.White, color = Color.White,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
@@ -1,6 +1,14 @@
package com.vitorpamplona.amethyst.ui.components 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.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.LocalTextStyle
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
@@ -11,12 +19,31 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.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.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 androidx.navigation.NavController
import coil.compose.AsyncImage
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.service.NIP30Parser
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
@@ -64,28 +91,28 @@ private fun DisplayEvent(
if (note.event is ChannelCreateEvent) { if (note.event is ChannelCreateEvent) {
CreateClickableText( CreateClickableText(
note.idDisplayNote(), note.idDisplayNote(),
nip19.additionalChars, "${nip19.additionalChars} ",
"Channel/${nip19.hex}", "Channel/${nip19.hex}",
navController navController
) )
} else if (note.event is PrivateDmEvent) { } else if (note.event is PrivateDmEvent) {
CreateClickableText( CreateClickableText(
note.idDisplayNote(), note.idDisplayNote(),
nip19.additionalChars, "${nip19.additionalChars} ",
"Room/${note.author?.pubkeyHex}", "Room/${note.author?.pubkeyHex}",
navController navController
) )
} else if (channel != null) { } else if (channel != null) {
CreateClickableText( CreateClickableText(
channel.toBestDisplayName(), channel.toBestDisplayName(),
nip19.additionalChars, "${nip19.additionalChars} ",
"Channel/${note.channel()?.idHex}", "Channel/${note.channel()?.idHex}",
navController navController
) )
} else { } else {
CreateClickableText( CreateClickableText(
note.idDisplayNote(), note.idDisplayNote(),
nip19.additionalChars, "${nip19.additionalChars} ",
"Event/${nip19.hex}", "Event/${nip19.hex}",
navController navController
) )
@@ -114,34 +141,34 @@ private fun DisplayNote(
noteBase?.let { noteBase?.let {
val noteState by it.live().metadata.observeAsState() val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return val note = remember(noteState) { noteState?.note } ?: return
val channel = note.channel() val channel = note.channel()
if (note.event is ChannelCreateEvent) { if (note.event is ChannelCreateEvent) {
CreateClickableText( CreateClickableText(
note.idDisplayNote(), note.idDisplayNote(),
nip19.additionalChars, "${nip19.additionalChars} ",
"Channel/${nip19.hex}", "Channel/${nip19.hex}",
navController navController
) )
} else if (note.event is PrivateDmEvent) { } else if (note.event is PrivateDmEvent) {
CreateClickableText( CreateClickableText(
note.idDisplayNote(), note.idDisplayNote(),
nip19.additionalChars, "${nip19.additionalChars} ",
"Room/${note.author?.pubkeyHex}", "Room/${note.author?.pubkeyHex}",
navController navController
) )
} else if (channel != null) { } else if (channel != null) {
CreateClickableText( CreateClickableText(
channel.toBestDisplayName(), channel.toBestDisplayName(),
nip19.additionalChars, "${nip19.additionalChars} ",
"Channel/${note.channel()?.idHex}", "Channel/${note.channel()?.idHex}",
navController navController
) )
} else { } else {
CreateClickableText( CreateClickableText(
note.idDisplayNote(), note.idDisplayNote(),
nip19.additionalChars, "${nip19.additionalChars} ",
"Note/${nip19.hex}", "Note/${nip19.hex}",
navController navController
) )
@@ -174,7 +201,7 @@ private fun DisplayAddress(
CreateClickableText( CreateClickableText(
note.idDisplayNote(), note.idDisplayNote(),
nip19.additionalChars, "${nip19.additionalChars} ",
"Note/${nip19.hex}", "Note/${nip19.hex}",
navController navController
) )
@@ -202,14 +229,19 @@ private fun DisplayUser(
userBase?.let { userBase?.let {
val userState by it.live().metadata.observeAsState() 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( if (userDisplayName != null) {
user.toBestDisplayName(), CreateClickableTextWithEmoji(
nip19.additionalChars, clickablePart = userDisplayName,
"User/${nip19.hex}", suffix = "${nip19.additionalChars} ",
navController tags = userTags,
) route = route,
navController = navController
)
}
} }
if (userBase == null) { if (userBase == null) {
@@ -231,9 +263,264 @@ fun CreateClickableText(clickablePart: String, suffix: String, route: String, na
withStyle( withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle() LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
) { ) {
append("$suffix ") append(suffix)
} }
}, },
onClick = { navController.navigate(route) } 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
)
}
@@ -109,7 +109,8 @@ class RichTextViewerState(
val content: String, val content: String,
val urlSet: Set<String>, val urlSet: Set<String>,
val imagesForPager: Map<String, ZoomableUrlContent>, val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent> val imageList: List<ZoomableUrlContent>,
val customEmoji: Map<String, String>
) )
@Composable @Composable
@@ -122,7 +123,7 @@ private fun RenderRegular(
navController: NavController navController: NavController
) { ) {
var processedState by remember { var processedState by remember {
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList())) mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
} }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -143,8 +144,10 @@ private fun RenderRegular(
}.associateBy { it.url } }.associateBy { it.url }
val imageList = imagesForPager.values.toList() val imageList = imagesForPager.values.toList()
if (urlSet.isNotEmpty()) { val emojiMap = tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList, emojiMap)
} }
} }
} }
@@ -167,6 +170,8 @@ private fun RenderRegular(
ZoomableContentView(img, state.imageList) ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) { } else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ") UrlPreview(word, "$word ")
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (word.startsWith("lnbc", true)) { } else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word) MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) { } else if (word.startsWith("lnurl", true)) {
@@ -235,6 +240,8 @@ private fun RenderRegular(
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) 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()) { } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word) ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { } 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 @Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) { private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
val myMarkDownStyle = richTextDefaults.copy( 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 baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) } var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) { LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word) val matcher = tagIndex.matcher(word)
val (index, suffix) = try { val (index, suffix) = try {
matcher.find() matcher.find()
@@ -630,8 +647,24 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
} }
baseUserPair?.let { baseUserPair?.let {
ClickableUserTag(it.first, navController) val innerUserState by it.first.live().metadata.observeAsState()
Text(text = "${it.second} ") 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 { baseNotePair?.let {
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey 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.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -149,7 +150,10 @@ fun AccountSwitchBottomSheet(
val npubShortHex = acc.npub.toShortenHex() val npubShortHex = acc.npub.toShortenHex()
user.bestDisplayName()?.let { user.bestDisplayName()?.let {
Text(it) CreateTextWithEmoji(
text = it,
tags = user.info?.latestMetadata?.tags
)
} }
Text(npubShortHex) Text(npubShortHex)
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient 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.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
@@ -168,8 +169,9 @@ fun ProfileContent(
}) })
) )
if (accountUser.bestDisplayName() != null) { if (accountUser.bestDisplayName() != null) {
Text( CreateTextWithEmoji(
accountUser.bestDisplayName() ?: "", text = accountUser.bestDisplayName() ?: "",
tags = accountUser.info?.latestMetadata?.tags,
modifier = Modifier modifier = Modifier
.padding(top = 7.dp) .padding(top = 7.dp)
.clickable(onClick = { .clickable(onClick = {
@@ -185,19 +187,22 @@ fun ProfileContent(
) )
} }
if (accountUser.bestUsername() != null) { if (accountUser.bestUsername() != null) {
Text( CreateTextWithEmoji(
" @${accountUser.bestUsername()}", text = " @${accountUser.bestUsername()}",
tags = accountUser.info?.latestMetadata?.tags,
color = Color.LightGray, color = Color.LightGray,
modifier = Modifier modifier = Modifier
.padding(top = 15.dp) .padding(top = 15.dp)
.clickable(onClick = { .clickable(
accountUser.let { onClick = {
navController.navigate("User/${it.pubkeyHex}") accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
} }
coroutineScope.launch { )
scaffoldState.drawerState.close()
}
})
) )
} }
Row( Row(
@@ -58,6 +58,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent 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.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@@ -218,14 +220,13 @@ fun ChatroomMessageCompose(
}) })
) )
Text( CreateClickableTextWithEmoji(
" ${author.toBestDisplayName()}", clickablePart = " ${author.toBestDisplayName()}",
suffix = "",
tags = author.info?.latestMetadata?.tags,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.clickable(onClick = { route = "User/${author.pubkeyHex}",
author.let { navController = navController
navController.navigate("User/${it.pubkeyHex}")
}
})
) )
} }
} }
@@ -251,33 +252,39 @@ fun ChatroomMessageCompose(
val event = note.event val event = note.event
if (event is ChannelCreateEvent) { if (event is ChannelCreateEvent) {
val channelInfo = event.channelInfo() val channelInfo = event.channelInfo()
Text( val text = note.author?.toBestDisplayName()
text = note.author?.toBestDisplayName() .toString() + " ${stringResource(R.string.created)} " + (
.toString() + " ${stringResource(R.string.created)} " + ( channelInfo.name
channelInfo.name ?: ""
?: "" ) + " ${stringResource(R.string.with_description_of)} '" + (
) + " ${stringResource(R.string.with_description_of)} '" + ( channelInfo.about
channelInfo.about ?: ""
?: "" ) + "', ${stringResource(R.string.and_picture)} '" + (
) + "', ${stringResource(R.string.and_picture)} '" + ( channelInfo.picture
channelInfo.picture ?: ""
?: "" ) + "'"
) + "'"
CreateTextWithEmoji(
text = text,
tags = note.author?.info?.latestMetadata?.tags
) )
} else if (event is ChannelMetadataEvent) { } else if (event is ChannelMetadataEvent) {
val channelInfo = event.channelInfo() val channelInfo = event.channelInfo()
Text( val text = note.author?.toBestDisplayName()
text = note.author?.toBestDisplayName() .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( channelInfo.name
channelInfo.name ?: ""
?: "" ) + "', ${stringResource(R.string.description_to)} '" + (
) + "$', {stringResource(R.string.description_to)} '" + ( channelInfo.about
channelInfo.about ?: ""
?: "" ) + "', ${stringResource(R.string.and_picture_to)} '" + (
) + "', ${stringResource(R.string.and_picture_to)} '" + ( channelInfo.picture
channelInfo.picture ?: ""
?: "" ) + "'"
) + "'"
CreateTextWithEmoji(
text = text,
tags = note.author?.info?.latestMetadata?.tags
) )
} else { } else {
val eventContent = accountViewModel.decrypt(note) val eventContent = accountViewModel.decrypt(note)
@@ -109,7 +109,7 @@ import com.vitorpamplona.amethyst.service.model.ReportedKey
import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableUrl 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.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
@@ -1245,13 +1245,15 @@ fun DisplayHighlight(
val userState by userBase.live().metadata.observeAsState() val userState by userBase.live().metadata.observeAsState()
val route = remember { "User/${userBase.pubkeyHex}" } val route = remember { "User/${userBase.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
if (userDisplayName != null) { if (userDisplayName != null) {
CreateClickableText( CreateClickableTextWithEmoji(
userDisplayName, clickablePart = userDisplayName,
"", suffix = "",
route, tags = userTags,
navController route = route,
navController = navController
) )
} }
} }
@@ -18,6 +18,7 @@ import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -59,8 +60,9 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
val innerUser = innerUserState?.user val innerUser = innerUserState?.user
innerUser?.let { myUser -> innerUser?.let { myUser ->
ClickableText( CreateClickableTextWithEmoji(
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"), clickablePart = "$prefix@${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) } onClick = { onUserTagClick(myUser) }
) )
@@ -196,8 +198,9 @@ fun ReplyInformationChannel(
val innerUser = innerUserState?.user val innerUser = innerUserState?.user
innerUser?.let { myUser -> innerUser?.let { myUser ->
ClickableText( CreateClickableTextWithEmoji(
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"), clickablePart = "$prefix@${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) } onClick = { onUserTagClick(myUser) }
) )
@@ -5,34 +5,31 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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.ui.components.CreateTextWithEmoji
@Composable @Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) { fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState() val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return val author = remember(noteState) { noteState?.note?.author } ?: return
val author = note.author UsernameDisplay(author, weight)
if (author != null) {
UsernameDisplay(author, weight)
}
} }
@Composable @Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) { fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState() 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() UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
val bestDisplayName = user.bestDisplayName()
val npubDisplay = user.pubkeyDisplayHex()
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, weight)
} }
@Composable @Composable
@@ -40,31 +37,36 @@ private fun UserNameDisplay(
bestUserName: String?, bestUserName: String?,
bestDisplayName: String?, bestDisplayName: String?,
npubDisplay: String, npubDisplay: String,
tags: List<List<String>>?,
modifier: Modifier modifier: Modifier
) { ) {
if (bestUserName != null && bestDisplayName != null) { if (bestUserName != null && bestDisplayName != null) {
Text( CreateTextWithEmoji(
bestDisplayName, text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( CreateTextWithEmoji(
"@$bestUserName", text = "@$bestUserName",
tags = tags,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier modifier = modifier
) )
} else if (bestDisplayName != null) { } else if (bestDisplayName != null) {
Text( CreateTextWithEmoji(
bestDisplayName, text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier modifier = modifier
) )
} else if (bestUserName != null) { } else if (bestUserName != null) {
Text( CreateTextWithEmoji(
"@$bestUserName", text = "@$bestUserName",
tags = tags,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -34,6 +34,7 @@ import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton 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.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner
@@ -83,10 +84,10 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
.background(MaterialTheme.colors.background) .background(MaterialTheme.colors.background)
) )
} }
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
Text( CreateTextWithEmoji(
user.bestDisplayName() ?: "", text = user.bestDisplayName() ?: user.bestUsername() ?: "",
modifier = Modifier.padding(top = 5.dp), tags = user.info?.latestMetadata?.tags,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 18.sp fontSize = 18.sp
) )
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse
import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView 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.DisplayNip05ProfileStatus
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.ResizeImage 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) { private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return val user = remember(userState) { userState?.user } ?: return
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Row(verticalAlignment = Alignment.Bottom) { (user.bestDisplayName() ?: user.bestUsername())?.let {
user.bestDisplayName()?.let { Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
Text( CreateTextWithEmoji(
it, text = it,
modifier = Modifier.padding(top = 7.dp), tags = tags,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 25.sp fontSize = 25.sp
) )
} }
} }
Row(verticalAlignment = Alignment.CenterVertically) {
if (user.bestDisplayName() != null) {
user.bestUsername()?.let { user.bestUsername()?.let {
Text( Row(
"@$it", verticalAlignment = Alignment.CenterVertically,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp) modifier = Modifier.padding(top = 1.dp, bottom = 1.dp)
) ) {
CreateTextWithEmoji(
text = "@$it",
tags = tags,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
} }
} }
@@ -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)
)
}
}