From 93c2041f0f7300868d7f5b3f41dd4ccf125a9a14 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 16:21:30 +0000 Subject: [PATCH] NIP-30: add optional emoji-set address and shortcode validation Per NIP-30 the emoji tag is ["emoji", , , ] with the fourth field optional. EmojiUrlTag only exposed the first three. Adds emojiSet as an optional field, preserves existing positional callers via default null, and introduces isValidShortcode() so callers can validate user input against the spec (alphanumeric, hyphens, underscores). --- .../quartz/nip30CustomEmoji/EmojiUrlTag.kt | 15 +++++- .../nip30CustomEmoji/TagArrayBuilderExt.kt | 6 +++ .../quartz/nip30CustomEmoji/Nip30Test.kt | 54 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) 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("絵文字")) + } }