diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt index 9f4672b26..ed9355a13 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt @@ -71,6 +71,20 @@ class MeetingSpaceEvent( fun endpoint() = tags.firstNotNullOfOrNull(EndpointUrlTag::parse) + /** + * Theme tints — every `["c", hex, "background"|"text"|"primary"]` + * tag, in event order. Clients use the first hex per target; + * extras are spec-defined as fallbacks for clients with palette + * support (out of scope for v1). + */ + fun colors() = tags.mapNotNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.ColorTag::parse) + + /** + * Background image / pattern for the room screen. Returns the + * first valid `["bg", url, mode]` tag. + */ + fun background() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.BackgroundTag::parse) + fun relays() = tags.mapNotNull(RelayListTag::parse).flatten() fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).flatten() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/BackgroundTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/BackgroundTag.kt new file mode 100644 index 000000000..366ede500 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/BackgroundTag.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 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.quartz.nip53LiveActivities.meetingSpaces.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * `["bg", "", ""]` on a kind-30312 audio-room + * event. Mode is one of [Mode.COVER] (default — fill behind the + * UI) or [Mode.TILE] (repeat as a pattern). Clients without + * background support fall back to the theme's solid background + * color (or default). + */ +data class BackgroundTag( + val url: String, + val mode: Mode, +) { + enum class Mode( + val code: String, + ) { + COVER("cover"), + TILE("tile"), + ; + + companion object { + fun fromCode(code: String?): Mode? = entries.firstOrNull { it.code.equals(code, ignoreCase = true) } + } + } + + companion object { + const val TAG_NAME = "bg" + + fun parse(tag: Array): BackgroundTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + // mode is optional; default to COVER when missing. + val mode = (tag.getOrNull(2)?.let(Mode::fromCode)) ?: Mode.COVER + return BackgroundTag(url = tag[1], mode = mode) + } + + fun assemble( + url: String, + mode: Mode = Mode.COVER, + ): Array = arrayOf(TAG_NAME, url, mode.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ColorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ColorTag.kt new file mode 100644 index 000000000..4c29d2ff2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ColorTag.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 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.quartz.nip53LiveActivities.meetingSpaces.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * `["c", "", ""]` on a kind-30312 audio-room event, + * where `target` is one of [Target.BACKGROUND], [Target.TEXT], + * or [Target.PRIMARY]. Used by the room author to tint the room + * UI; clients without theming support fall back to default theming. + * + * Strict 6-char hex parser — `#abc` shorthand and named colors are + * REJECTED so a typo in the room event can't crash the renderer. + */ +data class ColorTag( + /** 6-char uppercase hex without `#`. */ + val hex: String, + val target: Target, +) { + enum class Target( + val code: String, + ) { + BACKGROUND("background"), + TEXT("text"), + PRIMARY("primary"), + ; + + companion object { + fun fromCode(code: String): Target? = entries.firstOrNull { it.code.equals(code, ignoreCase = true) } + } + } + + companion object { + const val TAG_NAME = "c" + + private val hexPattern = Regex("^#?[0-9a-fA-F]{6}$") + + fun parse(tag: Array): ColorTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(hexPattern.matches(tag[1])) { return null } + val target = Target.fromCode(tag[2]) ?: return null + val hex = tag[1].removePrefix("#").uppercase() + return ColorTag(hex = hex, target = target) + } + + fun assemble( + hex: String, + target: Target, + ): Array { + require(hexPattern.matches(hex)) { "ColorTag: hex must be #?[0-9a-fA-F]{6}, got '$hex'" } + return arrayOf(TAG_NAME, hex.removePrefix("#").uppercase(), target.code) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/BackgroundTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/BackgroundTagTest.kt new file mode 100644 index 000000000..019ba2402 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/BackgroundTagTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 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.quartz.nip53LiveActivities.meetingSpaces.tags + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class BackgroundTagTest { + @Test + fun parseValidWithMode() { + val parsed = BackgroundTag.parse(arrayOf("bg", "https://i/x.jpg", "tile")) + assertEquals("https://i/x.jpg", parsed?.url) + assertEquals(BackgroundTag.Mode.TILE, parsed?.mode) + } + + @Test + fun defaultsToCoverWhenModeMissing() { + val parsed = BackgroundTag.parse(arrayOf("bg", "https://i/x.jpg")) + assertEquals(BackgroundTag.Mode.COVER, parsed?.mode) + } + + @Test + fun unknownModeFallsBackToCover() { + // Spec evolution — a future "blur" mode must not crash the + // parser. Falls back to COVER until the renderer learns the + // new mode. + val parsed = BackgroundTag.parse(arrayOf("bg", "https://i/x.jpg", "blur")) + assertEquals(BackgroundTag.Mode.COVER, parsed?.mode) + } + + @Test + fun rejectsEmptyUrl() { + assertNull(BackgroundTag.parse(arrayOf("bg", ""))) + } + + @Test + fun assembleRoundTrips() { + val tag = BackgroundTag.assemble("https://i/p.png", BackgroundTag.Mode.TILE) + val parsed = BackgroundTag.parse(tag) + assertEquals( + BackgroundTag(url = "https://i/p.png", mode = BackgroundTag.Mode.TILE), + parsed, + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ColorTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ColorTagTest.kt new file mode 100644 index 000000000..a34f12f09 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ColorTagTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 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.quartz.nip53LiveActivities.meetingSpaces.tags + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ColorTagTest { + @Test + fun parsesValidHexAndTarget() { + val parsed = ColorTag.parse(arrayOf("c", "#FF8800", "background")) + assertEquals("FF8800", parsed?.hex) + assertEquals(ColorTag.Target.BACKGROUND, parsed?.target) + } + + @Test + fun parsesWithoutHashPrefix() { + val parsed = ColorTag.parse(arrayOf("c", "abcdef", "primary")) + assertEquals("ABCDEF", parsed?.hex) + assertEquals(ColorTag.Target.PRIMARY, parsed?.target) + } + + @Test + fun rejectsShortHex() { + // `#abc` shorthand is rejected — keeps the parser strict so + // a typo'd room event can't crash the renderer. + assertNull(ColorTag.parse(arrayOf("c", "#abc", "text"))) + } + + @Test + fun rejectsNamedColors() { + assertNull(ColorTag.parse(arrayOf("c", "red", "text"))) + } + + @Test + fun rejectsUnknownTarget() { + assertNull(ColorTag.parse(arrayOf("c", "#abcdef", "accent"))) + } + + @Test + fun rejectsMissingTarget() { + assertNull(ColorTag.parse(arrayOf("c", "#abcdef"))) + } + + @Test + fun assembleProducesUppercaseNoHash() { + val tag = ColorTag.assemble("#abcdef", ColorTag.Target.TEXT) + assertEquals(arrayOf("c", "ABCDEF", "text").toList(), tag.toList()) + } + + @Test + fun roundTripPreservesEverything() { + val tag = ColorTag.assemble("FF00FF", ColorTag.Target.PRIMARY) + val parsed = ColorTag.parse(tag) + assertEquals(ColorTag(hex = "FF00FF", target = ColorTag.Target.PRIMARY), parsed) + } +}