diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NIP30Parser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NIP30Parser.kt new file mode 100644 index 000000000..0bc315756 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NIP30Parser.kt @@ -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 { + val matcher = customEmojiPattern.matcher(input) + val list = mutableListOf() + while (matcher.find()) { + list.add(matcher.group()) + } + + if (list.isEmpty()) { + return listOf(input) + } + + val regularChars = input.split(customEmojiPattern.toRegex()) + + var finalList = mutableListOf() + var index = 0 + for (e in regularChars) { + finalList.add(e) + if (index < list.size) { + finalList.add(list[index]) + } + index++ + } + return finalList + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 393aea7ec..a663d5d1e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -380,8 +380,9 @@ fun Notifying(baseMentions: List?, onClick: (User) -> Unit) { onClick(myUser) } ) { - Text( - "✖ ${myUser.toBestDisplayName()}", + CreateTextWithEmoji( + text = "✖ ${myUser.toBestDisplayName()}", + tags = myUser.info?.latestMetadata?.tags, color = Color.White, textAlign = TextAlign.Center ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index c94931a40..8f2b3368b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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>?, + 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, + 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>?, + 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>?, + 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): List { + 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, 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(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, + 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 + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index a833e977d..1e5c2f5d1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -109,7 +109,8 @@ class RichTextViewerState( val content: String, val urlSet: Set, val imagesForPager: Map, - val imageList: List + val imageList: List, + val customEmoji: Map ) @Composable @@ -122,7 +123,7 @@ private fun RenderRegular( navController: NavController ) { var processedState by remember { - mutableStateOf(RichTextViewerState(content, emptySet(), emptyMap(), emptyList())) + mutableStateOf(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) { + 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>, canPreview: Boolean, backgro var baseUserPair by remember { mutableStateOf?>(null) } var baseNotePair by remember { mutableStateOf?>(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>, 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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index d50ce32bc..8cb995ace 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 8b0501619..31166e172 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 21e7bb60c..3c6936b23 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index f028c9025..c93ff6bec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 18446bf03..2d7ab31a4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -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?, dupMentions: List?, 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) } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 836d1aa8b..a6a59f32b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -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>?, 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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index 30a7ba6b9..32d64d4da 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -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 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 2069612c7..485892a66 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -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) + ) + } } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt new file mode 100644 index 000000000..29016a0d1 --- /dev/null +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt @@ -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) + ) + } +}