From 9cd73c26633853f74c8a4e3af4a6bcdaed8e51d4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 5 Mar 2025 18:24:03 -0500 Subject: [PATCH] Adding secret emojis to reactions --- .../amethyst/service/EmojiUtils.kt | 5 ++ .../amethyst/ui/components/GlowingCard.kt | 11 ++- .../amethyst/ui/note/MultiSetCompose.kt | 75 +++++++++++++++- .../amethyst/ui/note/ReactionsRow.kt | 34 ++++++-- .../ui/note/UpdateReactionTypeDialog.kt | 87 +++++++++++++++++-- .../amethyst/commons/emojicoder/EmojiCoder.kt | 23 +++++ .../commons/emojicoder/EmojiCoderTest.kt | 6 ++ 7 files changed, 225 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt index 001c0faea..36433e85a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/EmojiUtils.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2 @@ -125,5 +126,9 @@ fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists): String { } } + if (EmojiCoder.isCoded(this)) { + return EmojiCoder.cropToFirstMessage(this) + } + return firstFullChar() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt index e8f47bdd6..beae54a70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GlowingCard.kt @@ -38,14 +38,19 @@ import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable fun AnimatedBorderTextCornerRadius( text: String, - modifier: Modifier, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + fontSize: TextUnit = 12.sp, ) { val infiniteTransition = rememberInfiniteTransition() val animatedFloatRestart = @@ -61,7 +66,7 @@ fun AnimatedBorderTextCornerRadius( Text( text = text, - fontSize = 12.sp, + fontSize = fontSize, modifier = modifier .drawBehind { @@ -84,6 +89,8 @@ fun AnimatedBorderTextCornerRadius( .CornerRadius(6.dp.toPx()), ) }.padding(3.dp), + color = color, + textAlign = textAlign, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index aa60ee5ba..1f0a9252e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -53,15 +54,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius +import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -92,6 +102,7 @@ import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.time.ExperimentalTime @@ -212,7 +223,18 @@ fun RenderLikeGallery( when (val shortReaction = reactionType) { "+" -> LikedIcon(modifier.size(Size19dp)) "-" -> Text(text = "\uD83D\uDC4E", modifier = modifier) - else -> Text(text = shortReaction, modifier = modifier) + else -> { + if (EmojiCoder.isCoded(shortReaction)) { + DisplaySecretEmojiAsReaction( + shortReaction, + modifier, + accountViewModel, + nav, + ) + } else { + Text(text = shortReaction, modifier = modifier) + } + } } } } @@ -222,6 +244,57 @@ fun RenderLikeGallery( } } +@Composable +fun DisplaySecretEmojiAsReaction( + reaction: String, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + var secretContent by remember { + mutableStateOf(null) + } + + var showPopup by remember { + mutableStateOf(false) + } + + LaunchedEffect(reaction) { + launch(Dispatchers.Default) { + secretContent = + CachedRichTextParser.parseText( + EmojiCoder.decode(reaction), + EmptyTagList, + ) + } + } + + val localSecretContent = secretContent + + AnimatedBorderTextCornerRadius( + reaction, + modifier.clickable { + showPopup = !showPopup + }, + ) + + if (localSecretContent != null && showPopup) { + val iconSizePx = with(LocalDensity.current) { -24.dp.toPx().toInt() } + + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, -iconSizePx), + onDismissRequest = { showPopup = false }, + properties = PopupProperties(focusable = true), + ) { + Surface(Modifier.padding(10.dp)) { + val color = remember { mutableStateOf(Color.Transparent) } + CoreSecretMessage(localSecretContent, null, 3, color, accountViewModel, nav) + } + } + } +} + @Composable fun DecryptAndRenderZapGallery( multiSetCard: MultiSetCard, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 0be5e8c36..9a9e06a76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -101,11 +101,13 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer @@ -953,7 +955,16 @@ private fun RenderReactionType( when (reactionType) { "+" -> LikedIcon(iconSizeModifier) "-" -> Text(text = "\uD83D\uDC4E", maxLines = 1, fontSize = iconFontSize) - else -> Text(text = reactionType, maxLines = 1, fontSize = iconFontSize) + else -> { + if (EmojiCoder.isCoded(reactionType)) { + AnimatedBorderTextCornerRadius( + reactionType, + fontSize = iconFontSize, + ) + } else { + Text(text = reactionType, maxLines = 1, fontSize = iconFontSize) + } + } } } } @@ -1501,6 +1512,7 @@ fun ReactionChoicePopupPeeview() { "\uD83D\uDE31", "\uD83E\uDD14", "\uD83D\uDE31", + "\uD83D\uDE80\uDB40\uDD58\uDB40\uDD64\uDB40\uDD64\uDB40\uDD60\uDB40\uDD63\uDB40\uDD2A\uDB40\uDD1F\uDB40\uDD1F\uDB40\uDD53\uDB40\uDD54\uDB40\uDD5E\uDB40\uDD1E\uDB40\uDD63\uDB40\uDD51\uDB40\uDD64\uDB40\uDD55\uDB40\uDD5C\uDB40\uDD5C\uDB40\uDD59\uDB40\uDD64\uDB40\uDD55\uDB40\uDD1E\uDB40\uDD55\uDB40\uDD51\uDB40\uDD62\uDB40\uDD64\uDB40\uDD58\uDB40\uDD1F\uDB40\uDD29\uDB40\uDD24\uDB40\uDD27\uDB40\uDD55\uDB40\uDD24\uDB40\uDD51\uDB40\uDD52\uDB40\uDD22\uDB40\uDD54\uDB40\uDD23\uDB40\uDD21\uDB40\uDD21\uDB40\uDD25\uDB40\uDD52\uDB40\uDD55\uDB40\uDD25\uDB40\uDD26\uDB40\uDD25\uDB40\uDD51\uDB40\uDD24\uDB40\uDD29\uDB40\uDD53\uDB40\uDD56\uDB40\uDD25\uDB40\uDD54\uDB40\uDD52\uDB40\uDD20\uDB40\uDD22\uDB40\uDD25\uDB40\uDD25\uDB40\uDD29\uDB40\uDD56\uDB40\uDD23\uDB40\uDD21\uDB40\uDD20\uDB40\uDD53\uDB40\uDD51\uDB40\uDD20\uDB40\uDD51\uDB40\uDD26\uDB40\uDD54\uDB40\uDD54\uDB40\uDD56\uDB40\uDD54\uDB40\uDD54\uDB40\uDD52\uDB40\uDD54\uDB40\uDD24\uDB40\uDD52\uDB40\uDD54\uDB40\uDD28\uDB40\uDD53\uDB40\uDD52\uDB40\uDD55\uDB40\uDD53\uDB40\uDD24\uDB40\uDD24\uDB40\uDD29\uDB40\uDD29\uDB40\uDD25\uDB40\uDD53\uDB40\uDD22\uDB40\uDD55\uDB40\uDD27\uDB40\uDD1E\uDB40\uDD67\uDB40\uDD55\uDB40\uDD52\uDB40\uDD60", ), onClick = {}, onChangeAmount = {}, @@ -1554,12 +1566,20 @@ fun RenderReaction(reactionType: String) { ) } else -> { - Text( - reactionType, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - fontSize = 22.sp, - ) + if (EmojiCoder.isCoded(reactionType)) { + AnimatedBorderTextCornerRadius( + reactionType, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 20.sp, + ) + } else { + Text( + reactionType, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + fontSize = 22.sp, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index e356b11b5..d1e2162b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -53,6 +53,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -77,9 +78,14 @@ import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.firstFullChar +import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius +import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.navigation.INav @@ -115,7 +121,13 @@ class UpdateReactionTypeViewModel : ViewModel() { fun toListOfChoices(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } fun addChoice() { - val newValue = nextChoice.text.trim().firstFullChar() + val newValue = + if (EmojiCoder.isCoded(nextChoice.text)) { + EmojiCoder.cropToFirstMessage(nextChoice.text) + } else { + nextChoice.text.trim().firstFullChar() + } + reactionSet = reactionSet + newValue nextChoice = TextFieldValue("") @@ -341,14 +353,77 @@ private fun RenderReactionOption( color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) - else -> - Text( - text = "$reactionType ✖", - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, + else -> { + if (EmojiCoder.isCoded(reactionType)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AnimatedBorderTextCornerRadius( + reactionType, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + Text( + text = " ✖", + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + } + } else { + Text( + text = "$reactionType ✖", + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + } + } + } + } + } +} + +@Composable +fun DisplaySecretEmoji( + text: String, + state: RichTextViewerState, + callbackUri: String?, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (canPreview && quotesLeft > 0) { + var secretContent by remember { + mutableStateOf(null) + } + + var showPopup by remember { + mutableStateOf(false) + } + + LaunchedEffect(text) { + launch(Dispatchers.Default) { + secretContent = + CachedRichTextParser.parseText( + EmojiCoder.decode(text), + state.tags, ) } } + + val localSecretContent = secretContent + + AnimatedBorderTextCornerRadius( + text, + Modifier.clickable { + showPopup = !showPopup + }, + ) + + if (localSecretContent != null && showPopup) { + CoreSecretMessage(localSecretContent, callbackUri, quotesLeft, backgroundColor, accountViewModel, nav) + } + } else { + Text(text) } } 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 index ddf192aa4..d3ddf3e97 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt @@ -106,4 +106,27 @@ object EmojiCoder { val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() } return String(decodedArray, Charsets.UTF_8) } + + @JvmStatic + fun cropToFirstMessage(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 + } + + return text.substring(0, i) + } } 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 index 36f45c18f..5157378bd 100644 --- a/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt @@ -38,6 +38,7 @@ class EmojiCoderTest { ) 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" + val HELLO_WORLD_WITH_EXTRAS = HELLO_WORLD + "askfasdf" } @Test @@ -55,6 +56,11 @@ class EmojiCoderTest { assertEquals("Hello, World!", EmojiCoder.decode(HELLO_WORLD)) } + @Test + fun testCrop() { + assertEquals(HELLO_WORLD, EmojiCoder.cropToFirstMessage(HELLO_WORLD_WITH_EXTRAS)) + } + @Test fun testEncodeDecode() { for (emoji in EMOJI_LIST) {