NIP-30: add optional emoji-set address and shortcode validation

Per NIP-30 the emoji tag is ["emoji", <shortcode>, <url>, <emoji-set-address>]
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).
This commit is contained in:
Claude
2026-04-20 16:21:30 +00:00
parent 4f2f8e180e
commit 93c2041f0f
3 changed files with 73 additions and 2 deletions
@@ -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<String>): EmojiUrlTag? =
if (tag.size > 2 && tag[0] == TAG_NAME) {
EmojiUrlTag(tag[1], tag[2])
EmojiUrlTag(tag[1], tag[2], tag.getOrNull(3))
} else {
null
}
@@ -25,4 +25,10 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : Event> TagArrayBuilder<T>.emoji(tag: EmojiUrlTag) = add(tag.toTagArray())
fun <T : Event> TagArrayBuilder<T>.emoji(
code: String,
url: String,
emojiSet: String? = null,
) = add(EmojiUrlTag(code, url, emojiSet).toTagArray())
fun <T : Event> TagArrayBuilder<T>.emojis(tags: List<EmojiUrlTag>) = addAll(tags.map { it.toTagArray() })
@@ -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("絵文字"))
}
}