Adding secret emojis to reactions

This commit is contained in:
Vitor Pamplona 2025-03-05 18:24:03 -05:00
parent 8722f37fa5
commit 9cd73c2663
7 changed files with 225 additions and 16 deletions

View File

@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2 fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2
@ -125,5 +126,9 @@ fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
} }
} }
if (EmojiCoder.isCoded(this)) {
return EmojiCoder.cropToFirstMessage(this)
}
return firstFullChar() return firstFullChar()
} }

View File

@ -38,14 +38,19 @@ import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke 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.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@Composable @Composable
fun AnimatedBorderTextCornerRadius( fun AnimatedBorderTextCornerRadius(
text: String, text: String,
modifier: Modifier, modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontSize: TextUnit = 12.sp,
) { ) {
val infiniteTransition = rememberInfiniteTransition() val infiniteTransition = rememberInfiniteTransition()
val animatedFloatRestart = val animatedFloatRestart =
@ -61,7 +66,7 @@ fun AnimatedBorderTextCornerRadius(
Text( Text(
text = text, text = text,
fontSize = 12.sp, fontSize = fontSize,
modifier = modifier =
modifier modifier
.drawBehind { .drawBehind {
@ -84,6 +89,8 @@ fun AnimatedBorderTextCornerRadius(
.CornerRadius(6.dp.toPx()), .CornerRadius(6.dp.toPx()),
) )
}.padding(3.dp), }.padding(3.dp),
color = color,
textAlign = textAlign,
) )
} }

View File

@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@ -53,15 +54,24 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.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.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User 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.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@ -92,6 +102,7 @@ import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@ -212,7 +223,18 @@ fun RenderLikeGallery(
when (val shortReaction = reactionType) { when (val shortReaction = reactionType) {
"+" -> LikedIcon(modifier.size(Size19dp)) "+" -> LikedIcon(modifier.size(Size19dp))
"-" -> Text(text = "\uD83D\uDC4E", modifier = modifier) "-" -> 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<RichTextViewerState?>(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 @Composable
fun DecryptAndRenderZapGallery( fun DecryptAndRenderZapGallery(
multiSetCard: MultiSetCard, multiSetCard: MultiSetCard,

View File

@ -101,11 +101,13 @@ import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled 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.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
@ -953,7 +955,16 @@ private fun RenderReactionType(
when (reactionType) { when (reactionType) {
"+" -> LikedIcon(iconSizeModifier) "+" -> LikedIcon(iconSizeModifier)
"-" -> Text(text = "\uD83D\uDC4E", maxLines = 1, fontSize = iconFontSize) "-" -> 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", "\uD83D\uDE31",
"\uD83E\uDD14", "\uD83E\uDD14",
"\uD83D\uDE31", "\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 = {}, onClick = {},
onChangeAmount = {}, onChangeAmount = {},
@ -1554,12 +1566,20 @@ fun RenderReaction(reactionType: String) {
) )
} }
else -> { else -> {
Text( if (EmojiCoder.isCoded(reactionType)) {
reactionType, AnimatedBorderTextCornerRadius(
color = MaterialTheme.colorScheme.onBackground, reactionType,
maxLines = 1, color = MaterialTheme.colorScheme.onBackground,
fontSize = 22.sp, fontSize = 20.sp,
) )
} else {
Text(
reactionType,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
fontSize = 22.sp,
)
}
} }
} }
} }

View File

@ -53,6 +53,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
@ -77,9 +78,14 @@ import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R 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.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.firstFullChar 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.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
@ -115,7 +121,13 @@ class UpdateReactionTypeViewModel : ViewModel() {
fun toListOfChoices(commaSeparatedAmounts: String): List<Long> = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } fun toListOfChoices(commaSeparatedAmounts: String): List<Long> = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 }
fun addChoice() { 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 reactionSet = reactionSet + newValue
nextChoice = TextFieldValue("") nextChoice = TextFieldValue("")
@ -341,14 +353,77 @@ private fun RenderReactionOption(
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
else -> else -> {
Text( if (EmojiCoder.isCoded(reactionType)) {
text = "$reactionType", Row(verticalAlignment = Alignment.CenterVertically) {
color = MaterialTheme.colorScheme.onBackground, AnimatedBorderTextCornerRadius(
textAlign = TextAlign.Center, 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<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (canPreview && quotesLeft > 0) {
var secretContent by remember {
mutableStateOf<RichTextViewerState?>(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)
} }
} }

View File

@ -106,4 +106,27 @@ object EmojiCoder {
val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() } val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() }
return String(decodedArray, Charsets.UTF_8) return String(decodedArray, Charsets.UTF_8)
} }
@JvmStatic
fun cropToFirstMessage(text: String): String {
val decoded = mutableListOf<Int>()
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)
}
} }

View File

@ -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 = "\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 @Test
@ -55,6 +56,11 @@ class EmojiCoderTest {
assertEquals("Hello, World!", EmojiCoder.decode(HELLO_WORLD)) assertEquals("Hello, World!", EmojiCoder.decode(HELLO_WORLD))
} }
@Test
fun testCrop() {
assertEquals(HELLO_WORLD, EmojiCoder.cropToFirstMessage(HELLO_WORLD_WITH_EXTRAS))
}
@Test @Test
fun testEncodeDecode() { fun testEncodeDecode() {
for (emoji in EMOJI_LIST) { for (emoji in EMOJI_LIST) {