diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt deleted file mode 100644 index bbe1903b2..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip30CustomEmoji.kt +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service - -import androidx.compose.runtime.Immutable -import java.util.regex.Pattern - -@Immutable -class Nip30CustomEmoji { - 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()) - - val 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/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 1bf1cfe03..e89bbe9b1 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 @@ -30,7 +30,6 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -59,21 +58,17 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.Nip30CustomEmoji import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.encoders.Nip19 +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun ClickableRoute( @@ -155,7 +150,7 @@ private fun DisplayNoteLink( val channelHex = remember(noteState) { note.channelHex() } val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } - val addedCharts = remember { "${nip19.additionalChars}" } + val addedCharts = nip19.additionalChars if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.KIND) { CreateClickableText( @@ -216,11 +211,10 @@ private fun DisplayAddress( val route = remember(noteState) { "Note/${nip19.hex}" } val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } - val addedCharts = remember { "${nip19.additionalChars}" } CreateClickableText( clickablePart = displayName, - suffix = addedCharts, + suffix = nip19.additionalChars, route = route, nav = nav, ) @@ -281,12 +275,15 @@ private fun RenderUserAsClickableText( userDisplayName?.let { CreateClickableTextWithEmoji( clickablePart = it, - suffix = nip19.additionalChars, maxLines = 1, route = route, nav = nav, tags = userTags, ) + + nip19.additionalChars.ifBlank { null }?.let { + Text(text = it, maxLines = 1) + } } } @@ -367,6 +364,74 @@ fun ClickableText( ) } +@Composable +fun CustomEmojiChecker( + text: String, + tags: ImmutableListOfLists?, + onRegularText: @Composable (String) -> Unit, + onEmojiText: @Composable (ImmutableList) -> Unit, +) { + val mayContainEmoji by remember(text, tags) { + mutableStateOf(Nip30CustomEmoji.fastMightContainEmoji(text, tags)) + } + + if (mayContainEmoji) { + var emojiList by + remember(text, tags) { + mutableStateOf?>(null) + } + + LaunchedEffect(text, tags) { + val newEmojiList = Nip30CustomEmoji.assembleAnnotatedList(text, tags) + if (newEmojiList != null) { + emojiList = newEmojiList + } + } + + emojiList?.let { + onEmojiText(it) + } ?: run { + onRegularText(text) + } + } else { + onRegularText(text) + } +} + +@Composable +fun CustomEmojiChecker( + text: String, + emojis: ImmutableMap, + onRegularText: @Composable (String) -> Unit, + onEmojiText: @Composable (ImmutableList) -> Unit, +) { + val mayContainEmoji by remember(text, emojis) { + mutableStateOf(Nip30CustomEmoji.fastMightContainEmoji(text, emojis)) + } + + if (mayContainEmoji) { + var emojiList by + remember(text, emojis) { + mutableStateOf?>(null) + } + + LaunchedEffect(text, emojis) { + val newEmojiList = Nip30CustomEmoji.assembleAnnotatedList(text, emojis) + if (newEmojiList != null) { + emojiList = newEmojiList + } + } + + emojiList?.let { + onEmojiText(it) + } ?: run { + onRegularText(text) + } + } else { + onRegularText(text) + } +} + @Composable fun CreateTextWithEmoji( text: String, @@ -379,52 +444,40 @@ fun CreateTextWithEmoji( overflow: TextOverflow = TextOverflow.Clip, modifier: Modifier = Modifier, ) { - var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } - - LaunchedEffect(key1 = text) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } - ?: emptyMap() - - if (emojis.isNotEmpty()) { - val newEmojiList = assembleAnnotatedList(text, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() - } - } - } - } - val textColor = color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } - if (emojiList.isEmpty()) { - Text( - text = text, - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier, - ) - } else { - val style = - LocalTextStyle.current - .merge( - TextStyle( - color = textColor, - textAlign = TextAlign.Unspecified, - fontWeight = fontWeight, - fontSize = fontSize, - ), - ) - .toSpanStyle() + CustomEmojiChecker( + text, + tags, + onEmojiText = { + val style = + LocalTextStyle.current + .merge( + TextStyle( + color = textColor, + textAlign = TextAlign.Unspecified, + fontWeight = fontWeight, + fontSize = fontSize, + ), + ) + .toSpanStyle() - InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) - } + InLineIconRenderer(it, style, fontSize, maxLines, overflow, modifier) + }, + onRegularText = { + Text( + text = it, + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + ) + }, + ) } @Composable @@ -439,51 +492,43 @@ fun CreateTextWithEmoji( overflow: TextOverflow = TextOverflow.Clip, modifier: Modifier = Modifier, ) { - var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } - - if (emojis.isNotEmpty()) { - LaunchedEffect(key1 = text) { - launch(Dispatchers.Default) { - val newEmojiList = assembleAnnotatedList(text, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() - } - } - } - } - val textColor = color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } } - if (emojiList.isEmpty()) { - Text( - text = text, - color = textColor, - textAlign = textAlign, - fontWeight = fontWeight, - fontSize = fontSize, - maxLines = maxLines, - overflow = overflow, - modifier = modifier, - ) - } else { - val currentStyle = LocalTextStyle.current - val style = - remember(currentStyle) { - currentStyle - .merge( - TextStyle( - color = textColor, - textAlign = TextAlign.Unspecified, - fontWeight = fontWeight, - fontSize = fontSize, - ), - ) - .toSpanStyle() - } + CustomEmojiChecker( + text, + emojis, + onEmojiText = { + val currentStyle = LocalTextStyle.current + val style = + remember(currentStyle) { + currentStyle + .merge( + TextStyle( + color = textColor, + textAlign = TextAlign.Unspecified, + fontWeight = fontWeight, + fontSize = fontSize, + ), + ) + .toSpanStyle() + } - InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier) - } + InLineIconRenderer(it, style, fontSize, maxLines, overflow, modifier) + }, + onRegularText = { + Text( + text = it, + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + ) + }, + ) } @Composable @@ -494,46 +539,26 @@ fun CreateClickableTextWithEmoji( style: TextStyle, onClick: (Int) -> Unit, ) { - var emojiList by - remember(clickablePart) { mutableStateOf>(persistentListOf()) } - - LaunchedEffect(key1 = clickablePart) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } - ?: emptyMap() - - if (emojis.isNotEmpty()) { - val newEmojiList = assembleAnnotatedList(clickablePart, emojis) - if (newEmojiList.isNotEmpty()) { - emojiList = newEmojiList.toImmutableList() - } - } - } - } - - if (emojiList.isEmpty()) { - ClickableText( - text = AnnotatedString(clickablePart), - style = style, - maxLines = maxLines, - onClick = onClick, - ) - } else { - ClickableInLineIconRenderer(emojiList, maxLines, style.toSpanStyle()) { onClick(it) } - } + CustomEmojiChecker( + text = clickablePart, + tags = tags, + onRegularText = { + ClickableText( + text = AnnotatedString(clickablePart), + style = style, + maxLines = maxLines, + onClick = onClick, + ) + }, + onEmojiText = { + ClickableInLineIconRenderer(it, maxLines, style.toSpanStyle()) { onClick(it) } + }, + ) } -@Immutable -data class DoubleEmojiList( - val part1: ImmutableList, - val part2: ImmutableList, -) - @Composable fun CreateClickableTextWithEmoji( clickablePart: String, - suffix: String?, maxLines: Int = Int.MAX_VALUE, overrideColor: Color? = null, fontWeight: FontWeight = FontWeight.Normal, @@ -541,84 +566,33 @@ fun CreateClickableTextWithEmoji( nav: (String) -> Unit, tags: ImmutableListOfLists?, ) { - var emojiLists by remember(clickablePart) { mutableStateOf(null) } + CustomEmojiChecker( + text = clickablePart, + tags = tags, + onRegularText = { + CreateClickableText(it, null, maxLines, overrideColor, fontWeight, route, nav) + }, + onEmojiText = { + val clickablePartStyle = + SpanStyle( + color = overrideColor ?: MaterialTheme.colorScheme.primary, + fontWeight = fontWeight, + ) - LaunchedEffect(key1 = clickablePart) { - launch(Dispatchers.Default) { - val emojis = - tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } - ?: emptyMap() - - if (emojis.isNotEmpty()) { - val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis) - val newEmojiList2 = - suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList() - - if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) { - emojiLists = - DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList()) - } + ClickableInLineIconRenderer( + it, + maxLines, + clickablePartStyle, + ) { + nav(route) } - } - } - - if (emojiLists == null) { - CreateClickableText(clickablePart, suffix, maxLines, overrideColor, fontWeight, route, nav) - } else { - val clickablePartStyle = - SpanStyle( - color = overrideColor ?: MaterialTheme.colorScheme.primary, - fontWeight = fontWeight, - ) - - val nonClickablePartStyle = - SpanStyle( - color = overrideColor ?: MaterialTheme.colorScheme.onBackground, - fontWeight = fontWeight, - ) - - ClickableInLineIconRenderer( - emojiLists!!.part1, - maxLines, - clickablePartStyle, - ) { - nav(route) - } - - InLineIconRenderer( - emojiLists!!.part2, - nonClickablePartStyle, - maxLines = maxLines, - ) - } + }, + ) } -suspend fun assembleAnnotatedList( - text: String, - emojis: Map, -): ImmutableList { - return Nip30CustomEmoji() - .buildArray(text) - .map { - val url = emojis[it] - if (url != null) { - ImageUrlType(url) - } else { - TextType(it) - } - } - .toImmutableList() -} - -@Immutable open class Renderable() - -@Immutable class TextType(val text: String) : Renderable() - -@Immutable class ImageUrlType(val url: String) : Renderable() - @Composable fun ClickableInLineIconRenderer( - wordsInOrder: ImmutableList, + wordsInOrder: ImmutableList, maxLines: Int = Int.MAX_VALUE, style: SpanStyle, onClick: (Int) -> Unit, @@ -635,7 +609,7 @@ fun ClickableInLineIconRenderer( val inlineContent = wordsInOrder .mapIndexedNotNull { idx, value -> - if (value is ImageUrlType) { + if (value is Nip30CustomEmoji.ImageUrlType) { Pair( "inlineContent$idx", InlineTextContent( @@ -664,9 +638,9 @@ fun ClickableInLineIconRenderer( withStyle( style, ) { - if (value is TextType) { + if (value is Nip30CustomEmoji.TextType) { append(value.text) - } else if (value is ImageUrlType) { + } else if (value is Nip30CustomEmoji.ImageUrlType) { appendInlineContent("inlineContent$idx", "[icon]") } } @@ -692,7 +666,7 @@ fun ClickableInLineIconRenderer( @Composable fun InLineIconRenderer( - wordsInOrder: ImmutableList, + wordsInOrder: ImmutableList, style: SpanStyle, fontSize: TextUnit = TextUnit.Unspecified, maxLines: Int = Int.MAX_VALUE, @@ -711,7 +685,7 @@ fun InLineIconRenderer( val inlineContent = wordsInOrder .mapIndexedNotNull { idx, value -> - if (value is ImageUrlType) { + if (value is Nip30CustomEmoji.ImageUrlType) { Pair( "inlineContent$idx", InlineTextContent( @@ -741,9 +715,9 @@ fun InLineIconRenderer( withStyle( style, ) { - if (value is TextType) { + if (value is Nip30CustomEmoji.TextType) { append(value.text) - } else if (value is ImageUrlType) { + } else if (value is Nip30CustomEmoji.ImageUrlType) { appendInlineContent("inlineContent$idx", "[icon]") } } 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 51ffb3f11..c1c2fd419 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 @@ -65,6 +65,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em +import androidx.lifecycle.viewmodel.compose.viewModel import com.halilibo.richtext.markdown.Markdown import com.halilibo.richtext.markdown.MarkdownParseOptions import com.halilibo.richtext.ui.material3.Material3RichText @@ -86,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.RichTextViewerState import com.vitorpamplona.amethyst.commons.SchemelessUrlSegment import com.vitorpamplona.amethyst.commons.Segment import com.vitorpamplona.amethyst.commons.WithdrawSegment +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.HashtagIcon import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -94,6 +96,7 @@ import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.ui.note.LoadUser import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.toShortenHex +import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink import com.vitorpamplona.amethyst.ui.theme.Font17SP @@ -102,10 +105,14 @@ import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.ui.theme.markdownStyle import com.vitorpamplona.amethyst.ui.uriToRoute +import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists +import fr.acinq.secp256k1.Hex +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch fun isMarkdown(content: String): Boolean { @@ -136,15 +143,9 @@ fun RichTextViewer( } } -@Preview() +@Preview @Composable fun RenderRegularPreview() { - val backgroundColor = MaterialTheme.colorScheme.background - val backgroundColorState = - remember { - mutableStateOf(backgroundColor) - } - val nav: (String) -> Unit = {} Column(modifier = Modifier.padding(10.dp)) { @@ -171,6 +172,7 @@ fun RenderRegularPreview() { nav = nav, ) } + is HashTagSegment -> HashTag(word, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) @@ -178,27 +180,93 @@ fun RenderRegularPreview() { is RegularTextSegment -> Text(word.segmentText) } } + } +} - RenderRegular( - "#Amethyst v0.84.1: ncryptsec support (NIP-49)", - EmptyTagList, - ) { word, state -> - when (word) { - // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - // is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) - is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) - is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) - is WithdrawSegment -> MayBeWithdrawal(word.segmentText) - // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is EmailSegment -> ClickableEmail(word.segmentText) - is PhoneSegment -> ClickablePhone(word.segmentText) - // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) - // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) - // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> Text(word.segmentText) - } +@Preview +@Composable +fun RenderRegularPreview2() { + val nav: (String) -> Unit = {} + RenderRegular( + "#Amethyst v0.84.1: ncryptsec support (NIP-49)", + EmptyTagList, + ) { word, state -> + when (word) { + // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + // is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) + is WithdrawSegment -> MayBeWithdrawal(word.segmentText) + // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) + // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> Text(word.segmentText) + } + } +} + +@Composable +fun mockAccountViewModel(): AccountViewModel { + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() + sharedPreferencesViewModel.init() + + return AccountViewModel( + Account( + // blank keys + keyPair = + KeyPair( + privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"), + pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"), + forcePubKeyCheck = false, + ), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ), + sharedPreferencesViewModel.sharedPrefs, + ) +} + +@Preview +@Composable +fun RenderRegularPreview3() { + val tags = + ImmutableListOfLists( + arrayOf( + arrayOf("t", "ioメシヨソイゲーム"), + arrayOf("emoji", "_ri", "https://media.misskeyusercontent.com/emoji/_ri.png"), + arrayOf("emoji", "petthex_japanesecake", "https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif"), + arrayOf("emoji", "ai_nomming", "https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif"), + arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"), + ), + ) + val nav: (String) -> Unit = {} + val accountViewModel = mockAccountViewModel() + + RenderRegular( + "\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B\n" + + "#ioメシヨソイゲーム\n" + + "https://misskey.io/play/9g3qza4jow", + tags, + ) { word, state -> + when (word) { + // is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) + is InvoiceSegment -> MayBeInvoicePreview(word.segmentText) + is WithdrawSegment -> MayBeWithdrawal(word.segmentText) + // is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) + is PhoneSegment -> ClickablePhone(word.segmentText) + // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) + is HashTagSegment -> HashTag(word, nav) + // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) + // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is RegularTextSegment -> Text(word.segmentText) } } } @@ -629,23 +697,25 @@ fun HashTag( val primary = MaterialTheme.colorScheme.primary val background = MaterialTheme.colorScheme.onBackground val hashtagIcon: HashtagIcon? = - remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) } + remember(segment.segmentText) { checkForHashtagWithIcon(segment.hashtag, primary) } val regularText = remember { SpanStyle(color = background) } val clickableTextStyle = remember { SpanStyle(color = primary) } val annotatedTermsString = - remember { + remember(segment.segmentText) { buildAnnotatedString { withStyle(clickableTextStyle) { pushStringAnnotation("routeToHashtag", "") append("#${segment.hashtag}") + pop() } if (hashtagIcon != null) { withStyle(clickableTextStyle) { pushStringAnnotation("routeToHashtag", "") appendInlineContent("inlineContent", "[icon]") + pop() } } @@ -696,7 +766,12 @@ fun TagLink( if (it == null) { Text(text = word.segmentText) } else { - Row { DisplayUserFromTag(it, word.extras, nav) } + Row { + DisplayUserFromTag(it, nav) + word.extras?.let { + Text(text = it) + } + } } } } @@ -773,7 +848,6 @@ private fun DisplayNoteFromTag( @Composable private fun DisplayUserFromTag( baseUser: User, - addedChars: String?, nav: (String) -> Unit, ) { val route = remember { "User/${baseUser.pubkeyHex}" } @@ -781,12 +855,11 @@ private fun DisplayUserFromTag( val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) - Crossfade(targetState = meta) { + Crossfade(targetState = meta, label = "DisplayUserFromTag") { Row { val displayName = remember(it) { it?.bestDisplayName() ?: it?.bestUsername() ?: hex } CreateClickableTextWithEmoji( clickablePart = displayName, - suffix = addedChars, maxLines = 1, route = route, nav = nav, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 6888e012b..4856148f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -376,11 +376,13 @@ fun ImageUrlWithDownloadButton( withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") append("$url ") + pop() } withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") appendInlineContent("inlineContent", "[icon]") + pop() } withStyle(regularText) { append(" ") } @@ -606,6 +608,7 @@ private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) { withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") append(content.url + " ") + pop() } } else { withStyle(regularText) { append("Loading content...") } @@ -614,6 +617,7 @@ private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) { withStyle(clickableTextStyle) { pushStringAnnotation("routeToImage", "") appendInlineContent("inlineContent", "[icon]") + pop() } withStyle(regularText) { append(" ") } 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 626c5fc12..4d647c181 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 @@ -787,7 +787,6 @@ private fun DisplayMessageUsername( Spacer(modifier = StdHorzSpacer) CreateClickableTextWithEmoji( clickablePart = userDisplayName, - suffix = "", maxLines = 1, tags = userTags, fontWeight = FontWeight.Bold, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 31f905e94..3226d05cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -65,7 +65,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.components.ImageUrlType import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -90,10 +89,10 @@ import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.overPictureBackground import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.events.EmptyTagList import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.time.ExperimentalTime @@ -232,10 +231,7 @@ fun RenderLikeGallery( val url = noStartColon.substringAfter(":") val renderable = - listOf( - ImageUrlType(url), - ) - .toImmutableList() + persistentListOf(Nip30CustomEmoji.ImageUrlType(url)) InLineIconRenderer( renderable, 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 97ac90c4b..db4e2a79d 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 @@ -2930,7 +2930,6 @@ private fun LoadAndDisplayUser( if (userDisplayName != null) { CreateClickableTextWithEmoji( clickablePart = userDisplayName, - suffix = " ", maxLines = 1, route = route, nav = nav, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 4cc196fd0..098a56f4d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -99,9 +99,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.NewPostView -import com.vitorpamplona.amethyst.ui.components.ImageUrlType import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer -import com.vitorpamplona.amethyst.ui.components.TextType import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -128,6 +126,7 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -808,10 +807,9 @@ private fun RenderReactionType( if (reactionType.isNotEmpty() && reactionType[0] == ':') { val renderable = remember(reactionType) { - listOf( - ImageUrlType(reactionType.removePrefix(":").substringAfter(":")), + persistentListOf( + Nip30CustomEmoji.ImageUrlType(reactionType.removePrefix(":").substringAfter(":")), ) - .toImmutableList() } InLineIconRenderer( @@ -1284,11 +1282,10 @@ private fun ActionableReactionButton( val url = noStartColon.substringAfter(":") val renderable = - listOf( - ImageUrlType(url), - TextType(removeSymbol), + persistentListOf( + Nip30CustomEmoji.ImageUrlType(url), + Nip30CustomEmoji.TextType(removeSymbol), ) - .toImmutableList() InLineIconRenderer( renderable, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 4e098e6ca..b6861152c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -80,17 +80,17 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.firstFullChar import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveButton -import com.vitorpamplona.amethyst.ui.components.ImageUrlType import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer -import com.vitorpamplona.amethyst.ui.components.TextType import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.EmojiUrl import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @@ -283,11 +283,10 @@ private fun RenderReactionOption( val url = noStartColon.substringAfter(":") val renderable = - listOf( - ImageUrlType(url), - TextType(" ✖"), + persistentListOf( + Nip30CustomEmoji.ImageUrlType(url), + Nip30CustomEmoji.TextType(" ✖"), ) - .toImmutableList() InLineIconRenderer( renderable, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 57f32043f..f4cd1395e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -533,6 +533,7 @@ fun LoginPage( withStyle(clickableTextStyle) { pushStringAnnotation("openTerms", "") append(stringResource(R.string.terms_of_use)) + pop() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt index a12965b80..cd37006e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt @@ -197,6 +197,7 @@ fun SignUpPage( withStyle(clickableTextStyle) { pushStringAnnotation("openTerms", "") append(stringResource(R.string.terms_of_use)) + pop() } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index fb87fc20b..41f4eb250 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -169,6 +169,7 @@ private fun TranslationMessage( withStyle(clickableTextStyle) { pushStringAnnotation("langSettings", true.toString()) append(stringResource(R.string.translations_auto)) + pop() } append("-${stringResource(R.string.translations_translated_from)} ") @@ -176,6 +177,7 @@ private fun TranslationMessage( withStyle(clickableTextStyle) { pushStringAnnotation("showOriginal", true.toString()) append(Locale(source).displayName) + pop() } append(" ${stringResource(R.string.translations_to)} ") @@ -183,6 +185,7 @@ private fun TranslationMessage( withStyle(clickableTextStyle) { pushStringAnnotation("showOriginal", false.toString()) append(Locale(target).displayName) + pop() } } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt deleted file mode 100644 index 8f84a7daf..000000000 --- a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -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:", ""), - Nip30CustomEmoji().buildArray(input), - ) - } - - @Test() - fun parseEmojiInverted() { - val input = ":soapbox:Alex Gleason" - - assertEquals( - listOf("", ":soapbox:", "Alex Gleason"), - Nip30CustomEmoji().buildArray(input), - ) - } - - @Test() - fun parseEmoji2() { - val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo" - - assertEquals( - listOf("Hello ", ":gleasonator:", " 😂 ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"), - Nip30CustomEmoji().buildArray(input), - ) - - println(Nip30CustomEmoji().buildArray(input).joinToString(",")) - } - - @Test() - fun parseEmoji3() { - val input = "hello vitor: how can I help:" - - assertEquals( - listOf("hello vitor: how can I help:"), - Nip30CustomEmoji().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)"), - Nip30CustomEmoji().buildArray(input), - ) - } -} diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/RichTextParserTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/RichTextParserTest.kt index 0ca368f6e..ed9e608da 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/RichTextParserTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/RichTextParserTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.ImmutableListOfLists import org.junit.Test import org.junit.runner.RunWith @@ -4242,6 +4243,44 @@ class RichTextParserTest { } } + @Test + fun testJapaneseWithEmojis() { + val tags = + arrayOf( + arrayOf("t", "ioメシヨソイゲーム"), + arrayOf("emoji", "_ri", "https://media.misskeyusercontent.com/emoji/_ri.png"), + arrayOf("emoji", "petthex_japanesecake", "https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif"), + arrayOf("emoji", "ai_nomming", "https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif"), + arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"), + ) + val text = + "\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B\n" + + "#ioメシヨソイゲーム\n" + + "https://misskey.io/play/9g3qza4jow" + + val state = + RichTextParser().parseText(text, ImmutableListOfLists(tags)) + + printStateForDebug(state) + + val expectedResult = + listOf( + "Emoji(\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B)", + "HashTag(#ioメシヨソイゲーム)", + "Link(https://misskey.io/play/9g3qza4jow)", + ) + + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + org.junit.Assert.assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + ) + } + } + private fun printStateForDebug(state: com.vitorpamplona.amethyst.commons.RichTextViewerState) { state.paragraphs.forEach { paragraph -> paragraph.words.forEach { seg -> diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt index 98eaafe26..78c18b76d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/RichTextParser.kt @@ -24,6 +24,7 @@ import android.util.Log import android.util.Patterns import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.encoders.Nip54 import com.vitorpamplona.quartz.encoders.Nip92 import com.vitorpamplona.quartz.events.FileHeaderEvent @@ -108,8 +109,7 @@ class RichTextParser() { urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url } val imageList = imagesForPager.values.toList() - val emojiMap = - tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } + val emojiMap = Nip30CustomEmoji.createEmojiMap(tags) val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) @@ -205,7 +205,7 @@ class RichTextParser() { if (urls.contains(word)) return LinkSegment(word) - if (word.startsWith(":") && emojis.any { word.contains(it.key) }) return EmojiSegment(word) + if (Nip30CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word) if (word.startsWith("lnbc", true)) return InvoiceSegment(word) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip30CustomEmoji.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip30CustomEmoji.kt new file mode 100644 index 000000000..809a10fdb --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip30CustomEmoji.kt @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.encoders + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.events.ImmutableListOfLists +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.util.regex.Pattern + +class Nip30CustomEmoji { + companion object { + val customEmojiPattern: Pattern = + Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE) + + fun fastMightContainEmoji( + input: String, + allTags: ImmutableListOfLists?, + ): Boolean { + if (allTags == null) return false + if (allTags.lists.any { it.size > 2 && it[0] == "emoji" }) return true + return input.contains(":") + } + + fun fastMightContainEmoji( + input: String, + emojiPairs: Map, + ): Boolean { + if (emojiPairs.isEmpty()) return false + return input.contains(":") + } + + fun createEmojiMap(tags: ImmutableListOfLists): Map { + return tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } + } + + fun assembleAnnotatedList( + input: String, + allTags: ImmutableListOfLists?, + ): ImmutableList? { + if (allTags == null || allTags.lists.isEmpty()) return null + + val emojiPairs = createEmojiMap(allTags) + + return assembleAnnotatedList(input, emojiPairs) + } + + fun assembleAnnotatedList( + input: String, + emojiPairs: Map, + ): ImmutableList? { + val matcher = customEmojiPattern.matcher(input) + val emojiNamesInOrder = mutableListOf() + while (matcher.find()) { + emojiNamesInOrder.add(matcher.group()) + } + + if (emojiNamesInOrder.isEmpty()) { + return null + } + + val regularCharsInOrder = input.split(customEmojiPattern.toRegex()) + + val finalList = mutableListOf() + + // Merge the two lists in Order. + var index = 0 + for (word in regularCharsInOrder) { + if (word.isNotEmpty()) { + finalList.add(TextType(word)) + } + if (index < emojiNamesInOrder.size) { + val url = emojiPairs[emojiNamesInOrder[index]] + + if (url != null) { + finalList.add(ImageUrlType(url)) + } else { + if (word.isNotEmpty()) { + finalList.add(TextType(word)) + } + } + } + index++ + } + + return finalList.toImmutableList() + } + } + + @Immutable open class Renderable() + + @Immutable class TextType(val text: String) : Renderable() + + @Immutable class ImageUrlType(val url: String) : Renderable() +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip30Test.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip30Test.kt new file mode 100644 index 000000000..eb99cb9ae --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/Nip30Test.kt @@ -0,0 +1,168 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.encoders + +import com.vitorpamplona.quartz.events.ImmutableListOfLists +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import org.junit.Test + +class Nip30Test { + @Test() + fun parseEmoji() { + val tags = mapOf(":soapbox:" to "http://soapbox") + val input = "Alex Gleason :soapbox:" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(2, result!!.size) + + assertEquals( + "Alex Gleason ", + (result!![0] as Nip30CustomEmoji.TextType).text, + ) + + assertEquals( + "http://soapbox", + (result!![1] as Nip30CustomEmoji.ImageUrlType).url, + ) + } + + @Test() + fun parseEmojiInverted() { + val tags = mapOf(":soapbox:" to "http://soapbox") + val input = ":soapbox:Alex Gleason" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(2, result!!.size) + + assertEquals( + "http://soapbox", + (result!![0] as Nip30CustomEmoji.ImageUrlType).url, + ) + + assertEquals( + "Alex Gleason", + (result!![1] as Nip30CustomEmoji.TextType).text, + ) + } + + @Test() + fun parseEmoji2() { + val tags = + mapOf( + ":gleasonator:" to "http://gleasonator", + ":ablobcatrainbow:" to "http://ablobcatrainbow", + ":disputed:" to "http://disputed", + ) + val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(7, result!!.size) + + assertEquals("Hello ", (result!![0] as Nip30CustomEmoji.TextType).text) + + assertEquals("http://gleasonator", (result!![1] as Nip30CustomEmoji.ImageUrlType).url) + + assertEquals(" 😂 ", (result!![2] as Nip30CustomEmoji.TextType).text) + + assertEquals("http://ablobcatrainbow", (result!![3] as Nip30CustomEmoji.ImageUrlType).url) + + assertEquals(" ", (result!![4] as Nip30CustomEmoji.TextType).text) + + assertEquals("http://disputed", (result!![5] as Nip30CustomEmoji.ImageUrlType).url) + + assertEquals(" yolo", (result!![6] as Nip30CustomEmoji.TextType).text) + } + + @Test() + fun parseEmoji3() { + val tags = emptyMap() + val input = "hello vitor: how can I help:" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertNull(result) + } + + @Test() + fun parseEmoji4() { + val tags = mapOf(":vitor:" to "http://vitor") + val input = "hello :vitor: how :can I help:" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(3, result!!.size) + + assertEquals("hello ", (result!![0] as Nip30CustomEmoji.TextType).text) + + assertEquals("http://vitor", (result!![1] as Nip30CustomEmoji.ImageUrlType).url) + + assertEquals(" how :can I help:", (result!![2] as Nip30CustomEmoji.TextType).text) + } + + @Test() + fun parseJapanese() { + val tags = mapOf(":x30EDE:" to "http://x30EDE", ":\uD883\uDEDE:" to "http://\uD883\uDEDE") + val input = "\uD883\uDEDE\uD883\uDEDE麺の:x30EDE:。:\uD883\uDEDE:(Violation of NIP-30)" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, tags) + + assertEquals(3, result!!.size) + + assertEquals("\uD883\uDEDE\uD883\uDEDE麺の", (result!![0] as Nip30CustomEmoji.TextType).text) + assertEquals("http://x30EDE", (result!![1] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("。:\uD883\uDEDE:(Violation of NIP-30)", (result!![2] as Nip30CustomEmoji.TextType).text) + } + + @Test() + fun parseJapanese2() { + val tags = + arrayOf( + arrayOf("t", "ioメシヨソイゲーム"), + arrayOf("emoji", "_ri", "https://media.misskeyusercontent.com/emoji/_ri.png"), + arrayOf("emoji", "petthex_japanesecake", "https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif"), + arrayOf("emoji", "ai_nomming", "https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif"), + arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"), + ) + val input = + "\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B\n" + + "#ioメシヨソイゲーム\n" + + "https://misskey.io/play/9g3qza4jow" + + val result = Nip30CustomEmoji.assembleAnnotatedList(input, ImmutableListOfLists(tags)) + + assertEquals(9, result!!.size) + + var i = 0 + assertEquals("\u200B", (result!![i++] as Nip30CustomEmoji.TextType).text) + assertEquals("https://media.misskeyusercontent.com/emoji/_ri.png", (result!![i++] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("\u200B\u200B", (result!![i++] as Nip30CustomEmoji.TextType).text) + assertEquals("https://media.misskeyusercontent.com/emoji/_ri.png", (result!![i++] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("\u200Bはベイクドモチョチョ\u200B", (result!![i++] as Nip30CustomEmoji.TextType).text) + assertEquals("https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif", (result!![i++] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("\u200Bを食べました\u200B", (result!![i++] as Nip30CustomEmoji.TextType).text) + assertEquals("https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif", (result!![i++] as Nip30CustomEmoji.ImageUrlType).url) + assertEquals("\u200B\n#ioメシヨソイゲーム\nhttps://misskey.io/play/9g3qza4jow", (result!![i++] as Nip30CustomEmoji.TextType).text) + } +}