adds support for secret emojis

This commit is contained in:
Vitor Pamplona
2025-03-05 13:40:51 -05:00
parent 0580aef4a2
commit 7e2724ea31
7 changed files with 275 additions and 9 deletions

View File

@@ -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<CharArray>(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<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
}
val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() }
return String(decodedArray, Charsets.UTF_8)
}
}

View File

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

View File

@@ -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<String>,
val imagesForPager: ImmutableMap<String, MediaUrlContent>,
val imageList: ImmutableList<MediaUrlContent>,
val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ParagraphState>,
val tags: ImmutableListOfLists<String>,
)
@Immutable
@@ -80,6 +82,10 @@ class EmailSegment(
segment: String,
) : Segment(segment)
class SecretEmoji(
segment: String,
) : Segment(segment)
@Immutable
class PhoneSegment(
segment: String,

View File

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