diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt index a541d261e..4919ec960 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt @@ -26,16 +26,27 @@ import androidx.compose.runtime.Immutable data class EmojiUrlTag( val code: String, val url: String, + val emojiSet: String? = null, ) { fun encode(): String = ":$code:$url" fun toContentEncode(): String = contentEncode(code) - fun toTagArray() = arrayOf("emoji", code, url) + fun toTagArray() = + if (emojiSet != null) { + arrayOf(TAG_NAME, code, url, emojiSet) + } else { + arrayOf(TAG_NAME, code, url) + } companion object { const val TAG_NAME = "emoji" + // NIP-30: shortcode MUST be comprised of only alphanumeric characters, hyphens, and underscores. + private val SHORTCODE_PATTERN = Regex("^[A-Za-z0-9_\\-]+$") + + fun isValidShortcode(shortcode: String): Boolean = SHORTCODE_PATTERN.matches(shortcode) + fun contentEncode(code: String): String = ":$code:" fun decode(encodedEmojiSetup: String): EmojiUrlTag? { @@ -49,7 +60,7 @@ data class EmojiUrlTag( fun parse(tag: Array): EmojiUrlTag? = if (tag.size > 2 && tag[0] == TAG_NAME) { - EmojiUrlTag(tag[1], tag[2]) + EmojiUrlTag(tag[1], tag[2], tag.getOrNull(3)) } else { null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt index 0adfe102c..eedccf90c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/TagArrayBuilderExt.kt @@ -25,4 +25,10 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder fun TagArrayBuilder.emoji(tag: EmojiUrlTag) = add(tag.toTagArray()) +fun TagArrayBuilder.emoji( + code: String, + url: String, + emojiSet: String? = null, +) = add(EmojiUrlTag(code, url, emojiSet).toTagArray()) + fun TagArrayBuilder.emojis(tags: List) = addAll(tags.map { it.toTagArray() }) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/Nip30Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/Nip30Test.kt index 65ff12631..b76fc651a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/Nip30Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/Nip30Test.kt @@ -21,8 +21,11 @@ package com.vitorpamplona.quartz.nip30CustomEmoji import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class Nip30Test { @Test() @@ -164,4 +167,55 @@ class Nip30Test { assertEquals("https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif", (result[i++] as CustomEmoji.ImageUrlType).url) assertEquals("\u200B\n#ioメシヨソイゲーム\nhttps://misskey.io/play/9g3qza4jow", (result[i] as CustomEmoji.TextType).text) } + + @Test + fun emojiTagWithoutEmojiSetRoundtrip() { + val tag = EmojiUrlTag("soapbox", "http://soapbox") + assertContentEquals(arrayOf("emoji", "soapbox", "http://soapbox"), tag.toTagArray()) + assertEquals(tag, EmojiUrlTag.parse(tag.toTagArray())) + } + + @Test + fun emojiTagWithEmojiSetRoundtrip() { + val setAddress = "30030:abc123:emojis-fall" + val tag = EmojiUrlTag("soapbox", "http://soapbox", setAddress) + assertContentEquals(arrayOf("emoji", "soapbox", "http://soapbox", setAddress), tag.toTagArray()) + assertEquals(tag, EmojiUrlTag.parse(tag.toTagArray())) + } + + @Test + fun parseIgnoresTrailingFields() { + val tag = arrayOf("emoji", "soapbox", "http://soapbox", "30030:abc123:pack", "extra") + val parsed = EmojiUrlTag.parse(tag) + assertEquals(EmojiUrlTag("soapbox", "http://soapbox", "30030:abc123:pack"), parsed) + } + + @Test + fun parseRejectsWrongTagName() { + assertNull(EmojiUrlTag.parse(arrayOf("t", "soapbox", "http://soapbox"))) + } + + @Test + fun parseRejectsTooFewFields() { + assertNull(EmojiUrlTag.parse(arrayOf("emoji", "soapbox"))) + } + + @Test + fun validShortcodesAcceptAlphanumericUnderscoreHyphen() { + assertTrue(EmojiUrlTag.isValidShortcode("soapbox")) + assertTrue(EmojiUrlTag.isValidShortcode("Soap_Box-123")) + assertTrue(EmojiUrlTag.isValidShortcode("ABC")) + assertTrue(EmojiUrlTag.isValidShortcode("007")) + } + + @Test + fun invalidShortcodesRejected() { + assertFalse(EmojiUrlTag.isValidShortcode("")) + assertFalse(EmojiUrlTag.isValidShortcode("soap box")) + assertFalse(EmojiUrlTag.isValidShortcode("soap:box")) + assertFalse(EmojiUrlTag.isValidShortcode("soap.box")) + assertFalse(EmojiUrlTag.isValidShortcode("soap/box")) + assertFalse(EmojiUrlTag.isValidShortcode("soap!")) + assertFalse(EmojiUrlTag.isValidShortcode("絵文字")) + } }