NIP-30: use Address type for emoji-set reference instead of raw string

Makes the API safer: callers can't accidentally pass an unparsed or
malformed identifier. EmojiUrlTag.emojiSet is now Address?, serialized
via toValue() and parsed via Address.parse() (which rejects anything
that isn't a well-formed kind:pubkey:dTag).
This commit is contained in:
Claude
2026-04-20 16:35:29 +00:00
parent 93c2041f0f
commit 254f5bdcc2
3 changed files with 23 additions and 8 deletions
@@ -21,12 +21,13 @@
package com.vitorpamplona.quartz.nip30CustomEmoji package com.vitorpamplona.quartz.nip30CustomEmoji
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
@Immutable @Immutable
data class EmojiUrlTag( data class EmojiUrlTag(
val code: String, val code: String,
val url: String, val url: String,
val emojiSet: String? = null, val emojiSet: Address? = null,
) { ) {
fun encode(): String = ":$code:$url" fun encode(): String = ":$code:$url"
@@ -34,7 +35,7 @@ data class EmojiUrlTag(
fun toTagArray() = fun toTagArray() =
if (emojiSet != null) { if (emojiSet != null) {
arrayOf(TAG_NAME, code, url, emojiSet) arrayOf(TAG_NAME, code, url, emojiSet.toValue())
} else { } else {
arrayOf(TAG_NAME, code, url) arrayOf(TAG_NAME, code, url)
} }
@@ -60,7 +61,7 @@ data class EmojiUrlTag(
fun parse(tag: Array<String>): EmojiUrlTag? = fun parse(tag: Array<String>): EmojiUrlTag? =
if (tag.size > 2 && tag[0] == TAG_NAME) { if (tag.size > 2 && tag[0] == TAG_NAME) {
EmojiUrlTag(tag[1], tag[2], tag.getOrNull(3)) EmojiUrlTag(tag[1], tag[2], tag.getOrNull(3)?.let(Address::parse))
} else { } else {
null null
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.quartz.nip30CustomEmoji package com.vitorpamplona.quartz.nip30CustomEmoji
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
@@ -28,7 +29,7 @@ fun <T : Event> TagArrayBuilder<T>.emoji(tag: EmojiUrlTag) = add(tag.toTagArray(
fun <T : Event> TagArrayBuilder<T>.emoji( fun <T : Event> TagArrayBuilder<T>.emoji(
code: String, code: String,
url: String, url: String,
emojiSet: String? = null, emojiSet: Address? = null,
) = add(EmojiUrlTag(code, url, emojiSet).toTagArray()) ) = add(EmojiUrlTag(code, url, emojiSet).toTagArray())
fun <T : Event> TagArrayBuilder<T>.emojis(tags: List<EmojiUrlTag>) = addAll(tags.map { it.toTagArray() }) fun <T : Event> TagArrayBuilder<T>.emojis(tags: List<EmojiUrlTag>) = addAll(tags.map { it.toTagArray() })
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.quartz.nip30CustomEmoji package com.vitorpamplona.quartz.nip30CustomEmoji
import com.vitorpamplona.quartz.nip01Core.core.Address
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -28,6 +29,10 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
class Nip30Test { class Nip30Test {
companion object {
private const val TEST_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000abc"
}
@Test() @Test()
fun parseEmoji() { fun parseEmoji() {
val tags = mapOf(":soapbox:" to "http://soapbox") val tags = mapOf(":soapbox:" to "http://soapbox")
@@ -177,17 +182,25 @@ class Nip30Test {
@Test @Test
fun emojiTagWithEmojiSetRoundtrip() { fun emojiTagWithEmojiSetRoundtrip() {
val setAddress = "30030:abc123:emojis-fall" val setAddress = Address(30030, TEST_PUBKEY, "emojis-fall")
val tag = EmojiUrlTag("soapbox", "http://soapbox", setAddress) val tag = EmojiUrlTag("soapbox", "http://soapbox", setAddress)
assertContentEquals(arrayOf("emoji", "soapbox", "http://soapbox", setAddress), tag.toTagArray()) assertContentEquals(arrayOf("emoji", "soapbox", "http://soapbox", setAddress.toValue()), tag.toTagArray())
assertEquals(tag, EmojiUrlTag.parse(tag.toTagArray())) assertEquals(tag, EmojiUrlTag.parse(tag.toTagArray()))
} }
@Test @Test
fun parseIgnoresTrailingFields() { fun parseIgnoresTrailingFields() {
val tag = arrayOf("emoji", "soapbox", "http://soapbox", "30030:abc123:pack", "extra") val setAddress = Address(30030, TEST_PUBKEY, "pack")
val tag = arrayOf("emoji", "soapbox", "http://soapbox", setAddress.toValue(), "extra")
val parsed = EmojiUrlTag.parse(tag) val parsed = EmojiUrlTag.parse(tag)
assertEquals(EmojiUrlTag("soapbox", "http://soapbox", "30030:abc123:pack"), parsed) assertEquals(EmojiUrlTag("soapbox", "http://soapbox", setAddress), parsed)
}
@Test
fun parseMalformedEmojiSetAddressYieldsNull() {
val tag = arrayOf("emoji", "soapbox", "http://soapbox", "not-an-address")
val parsed = EmojiUrlTag.parse(tag)
assertEquals(EmojiUrlTag("soapbox", "http://soapbox", null), parsed)
} }
@Test @Test