From 7e2724ea310396ee8776574047e58052eb09b390 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 5 Mar 2025 13:40:51 -0500 Subject: [PATCH] adds support for secret emojis --- .../amethyst/ui/components/CashuRedeem.kt | 9 +- .../amethyst/ui/components/RichTextViewer.kt | 83 ++++++++++++- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 2 + .../amethyst/commons/emojicoder/EmojiCoder.kt | 109 ++++++++++++++++++ .../commons/richtext/RichTextParser.kt | 4 + .../richtext/RichTextParserSegments.kt | 8 +- .../commons/emojicoder/EmojiCoderTest.kt | 69 +++++++++++ 7 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt create mode 100644 commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index 47f87f4bb..d50594d3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -44,7 +44,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -69,7 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AmethystTheme -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.SmallishBorder @@ -151,11 +150,7 @@ fun CashuPreviewNew( val clipboardManager = LocalClipboardManager.current Card( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) - .clip(shape = QuoteBorder), + modifier = CashuCardBorders, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 3c957c7df..362e40573 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import com.vitorpamplona.amethyst.commons.compose.produceCachedState +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.richtext.Base64Segment import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment @@ -77,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment +import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment import com.vitorpamplona.amethyst.model.HashtagIcon @@ -91,13 +93,17 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.text.Typography.paragraph fun isMarkdown(content: String): Boolean = content.startsWith("> ") || @@ -375,6 +381,7 @@ private fun RenderWordWithoutPreview( is WithdrawSegment -> Text(word.segmentText) is CashuSegment -> Text(word.segmentText) is EmailSegment -> ClickableEmail(word.segmentText) + is SecretEmoji -> Text(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) is HashTagSegment -> HashTag(word, nav) @@ -403,6 +410,7 @@ private fun RenderWordWithPreview( is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) + is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) is HashTagSegment -> HashTag(word, nav) @@ -510,6 +518,79 @@ fun DisplayFullNote( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DisplaySecretEmoji( + segment: SecretEmoji, + state: RichTextViewerState, + callbackUri: String?, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (canPreview && quotesLeft > 0) { + var secretContent by remember { + mutableStateOf(null) + } + + LaunchedEffect(segment) { + launch(Dispatchers.Default) { + secretContent = + CachedRichTextParser.parseText( + EmojiCoder.decode(segment.segmentText), + state.tags, + ) + } + } + + secretContent?.let { + if (it.paragraphs.size == 1) { + Text(segment.segmentText) + it.paragraphs[0].words.forEach { word -> + RenderWordWithPreview( + word, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } else if (it.paragraphs.size > 1) { + val spaceWidth = measureSpaceWidth(LocalTextStyle.current) + + Column(CashuCardBorders) { + it.paragraphs.forEach { paragraph -> + FlowRow( + modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + ) { + paragraph.words.forEach { word -> + RenderWordWithPreview( + word, + state, + backgroundColor, + quotesLeft, + callbackUri, + accountViewModel, + nav, + ) + } + } + } + } + } else { + Text(segment.segmentText) + } + } + } else { + Text(segment.segmentText) + } +} + @Composable fun HashTag( segment: HashTagSegment, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 114676679..09315da25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -203,6 +203,8 @@ val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp) val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp) val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp) +val CashuCardBorders = Modifier.fillMaxWidth().padding(10.dp).clip(shape = QuoteBorder) + val EditFieldModifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp).fillMaxWidth() val EditFieldTrailingIconModifier = Modifier.height(26.dp).padding(start = 5.dp, end = 0.dp) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt new file mode 100644 index 000000000..ddf192aa4 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt @@ -0,0 +1,109 @@ +/** + * 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.commons.emojicoder + +object EmojiCoder { + // Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html + // VS1..=VS16 + const val VARIATION_SELECTOR_START = 0xfe00 + const val VARIATION_SELECTOR_END = 0xfe0f + + // Variation selectors supplement https://unicode.org/charts/nameslist/n_E0100.html + // VS17..=VS256 + const val VARIATION_SELECTOR_SUPPLEMENT_START = 0xe0100 + const val VARIATION_SELECTOR_SUPPLEMENT_END = 0xe01ef + + val toVariationArray = + Array(256) { + // converts to UTF-16. Always char[2] back + when (it) { + in 0..15 -> Character.toChars(VARIATION_SELECTOR_START + it) + in 16..255 -> Character.toChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16) + else -> throw RuntimeException("This should never happen") + } + } + + fun fromVariationSelector(codePoint: Int): Int? = + when (codePoint) { + in VARIATION_SELECTOR_START..VARIATION_SELECTOR_END -> codePoint - VARIATION_SELECTOR_START + in VARIATION_SELECTOR_SUPPLEMENT_START..VARIATION_SELECTOR_SUPPLEMENT_END -> codePoint - VARIATION_SELECTOR_SUPPLEMENT_START + 16 + else -> null + } + + fun isVariationChar(charCode: Int) = + charCode in VARIATION_SELECTOR_START..VARIATION_SELECTOR_END || + charCode in VARIATION_SELECTOR_SUPPLEMENT_START..VARIATION_SELECTOR_SUPPLEMENT_END + + @JvmStatic + fun isCoded(text: String): Boolean { + if (text.length <= 3) return false + + if (!isVariationChar(text.codePointAt(text.length - 2))) { + return false + } + + if (text.length > 4 && !isVariationChar(text.codePointAt(text.length - 4))) { + return false + } + + return true + } + + @JvmStatic + fun encode( + emoji: String, + text: String, + ): String { + val input = text.toByteArray(Charsets.UTF_8) + val out = CharArray(input.size * 2) + var outIdx = 0 + for (i in 0 until input.size) { + val chars = toVariationArray[input[i].toInt() and 0xFF] + out[outIdx++] = chars[0] + out[outIdx++] = chars[1] + } + return emoji + String(out) + } + + @JvmStatic + fun decode(text: String): String { + val decoded = mutableListOf() + + var i = 0 + while (i < text.length) { + val codePoint = text.codePointAt(i) + val byte = fromVariationSelector(codePoint) + + if (byte == null && decoded.isNotEmpty()) { + break + } else if (byte == null) { + i += Character.charCount(codePoint) // Advance index by correct number of chars + continue + } + + decoded.add(byte) + i += Character.charCount(codePoint) // Advance index by correct number of chars + } + + val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() } + return String(decodedArray, Charsets.UTF_8) + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index ee473f7fa..398e3eb5b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/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.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji @@ -160,6 +161,7 @@ class RichTextParser { imagesForPagerWithBase64.values.toImmutableList(), emojiMap.toImmutableMap(), segments, + tags, ) } @@ -255,6 +257,8 @@ class RichTextParser { if (word.startsWith("#")) return parseHash(word, tags) + if (EmojiCoder.isCoded(word)) return SecretEmoji(word) + if (word.contains("@")) { if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) return EmailSegment(word) } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index c4238e32a..e794e50ce 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -21,17 +21,19 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableSet @Immutable -data class RichTextViewerState( +class RichTextViewerState( val urlSet: ImmutableSet, val imagesForPager: ImmutableMap, val imageList: ImmutableList, val customEmoji: ImmutableMap, val paragraphs: ImmutableList, + val tags: ImmutableListOfLists, ) @Immutable @@ -80,6 +82,10 @@ class EmailSegment( segment: String, ) : Segment(segment) +class SecretEmoji( + segment: String, +) : Segment(segment) + @Immutable class PhoneSegment( segment: String, diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt b/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt new file mode 100644 index 000000000..768c13ad2 --- /dev/null +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt @@ -0,0 +1,69 @@ +/** + * 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.commons.emojicoder + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EmojiCoderTest { + companion object { + val EMOJI_LIST = arrayOf("πŸ˜€", "πŸ˜‚", "πŸ₯°", "😎", "πŸ€”", "πŸ‘", "πŸ‘Ž", "πŸ‘", "πŸ˜…", "🀝", "πŸŽ‰", "πŸŽ‚", "πŸ•", "🌈", "🌞", "πŸŒ™", "πŸ”₯", "πŸ’―", "πŸš€", "πŸ‘€", "πŸ’€", "πŸ₯Ή") + + val testStrings = + arrayOf( + "Hello, World!", + "Testing 123", + "Special chars: !@#$%^&*()", + "Unicode: δ½ ε₯½οΌŒδΈ–η•Œ", + " ", // space only + ) + + val HELLO_WORLD = "\uD83D\uDE00\uDB40\uDD38\uDB40\uDD55\uDB40\uDD5C\uDB40\uDD5C\uDB40\uDD5F\uDB40\uDD1C\uDB40\uDD10\uDB40\uDD47\uDB40\uDD5F\uDB40\uDD62\uDB40\uDD5C\uDB40\uDD54\uDB40\uDD11" + } + + @Test + fun testIsCoded() { + assertEquals(true, EmojiCoder.isCoded(HELLO_WORLD)) + } + + @Test + fun testEncode() { + assertEquals(HELLO_WORLD, EmojiCoder.encode("\uD83D\uDE00", "Hello, World!")) + } + + @Test + fun testDecode() { + assertEquals("Hello, World!", EmojiCoder.decode(HELLO_WORLD)) + } + + @Test + fun testEncodeDecode() { + for (emoji in EMOJI_LIST) { + for (sentence in testStrings) { + val encoded = EmojiCoder.encode(emoji, sentence) + val decoded = EmojiCoder.decode(encoded) + assertEquals(sentence, decoded) + assertTrue("Failed sentence for emoji $emoji with sentence `$sentence`: `$encoded`", EmojiCoder.isCoded(encoded)) + } + } + } +}