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
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>): String {
}
}
if (EmojiCoder.isCoded(this)) {
return EmojiCoder.cropToFirstMessage(this)
}
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.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,
)
}

View File

@ -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<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
fun DecryptAndRenderZapGallery(
multiSetCard: MultiSetCard,

View File

@ -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,
)
}
}
}
}

View File

@ -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<Long> = 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<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() }
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_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) {