mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-03-17 21:31:57 +01:00
adds support for secret emojis
This commit is contained in:
parent
0580aef4a2
commit
7e2724ea31
@ -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,
|
||||
|
@ -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<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (canPreview && quotesLeft > 0) {
|
||||
var secretContent by remember {
|
||||
mutableStateOf<RichTextViewerState?>(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,
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user