From 5a5eaa3b500b4d449f08dbc9fb5cd981af0854b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 21:32:59 +0000 Subject: [PATCH] feat(quartz): augment kind-10312 presence with publishing + onstage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two NIP-53 presence tags used by nostrnests' room UI: ["publishing", "0|1"] — peer is actively pushing audio packets ["onstage", "0|1"] — peer holds a speaker slot vs audience These complement the existing "hand" and "muted" tags. They're independent: a speaker can be onstage but paused (onstage=1, publishing=0), or broadcasting silently (onstage=1, publishing=1, muted=1). The participant grid (Tier 2) and listener counter (Tier 1 #8) consume them. - PublishingTag / OnstageTag classes mirror HandRaisedTag's parse + assemble shape exactly so the DSL (TagArrayBuilderExt) extends uniformly. - MeetingRoomPresenceEvent.publishing() / onstage() accessor parsers return Boolean? (null when the tag isn't present) so callers can distinguish "explicitly false" from "absent". - The MeetingSpaceEvent overload of build() now accepts both fields as nullable params; the MeetingRoomEvent overload deliberately doesn't (those are tier-2 video meetings, not Clubhouse rooms). - Round-trip tests pin the wire format and the "absent → null" behaviour. --- .../presence/MeetingRoomPresenceEvent.kt | 12 +++++ .../presence/TagArrayBuilderExt.kt | 6 +++ .../presence/tags/OnstageTag.kt | 52 +++++++++++++++++++ .../presence/tags/PublishingTag.kt | 52 +++++++++++++++++++ .../presence/MeetingRoomPresenceEventTest.kt | 39 ++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/OnstageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/PublishingTag.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt index ff494f9e9..1a88ebd61 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.MutedTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.OnstageTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.PublishingTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -57,6 +59,12 @@ class MeetingRoomPresenceEvent( fun muted() = tags.firstNotNullOfOrNull(MutedTag::parse) + /** True when the peer is actively pushing audio packets to the relay. */ + fun publishing() = tags.firstNotNullOfOrNull(PublishingTag::parse) + + /** True when the peer holds a speaker slot (vs. pure audience). */ + fun onstage() = tags.firstNotNullOfOrNull(OnstageTag::parse) + companion object Companion { const val KIND = 10312 const val ALT = "Room Presence tag" @@ -93,6 +101,8 @@ class MeetingRoomPresenceEvent( root: MeetingSpaceEvent, handRaised: Boolean? = null, muted: Boolean? = null, + publishing: Boolean? = null, + onstage: Boolean? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -102,6 +112,8 @@ class MeetingRoomPresenceEvent( handRaised?.let { handRaised(it) } muted?.let { muted(it) } + publishing?.let { publishing(it) } + onstage?.let { onstage(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt index 133dfbc6b..37ae7110e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEve import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.MutedTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.OnstageTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.PublishingTag fun TagArrayBuilder.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toTagArray()) @@ -35,3 +37,7 @@ fun TagArrayBuilder.roomMeeting(rep: EventHintBundle.handRaised(raised: Boolean) = addUnique(HandRaisedTag.assemble(raised)) fun TagArrayBuilder.muted(muted: Boolean) = addUnique(MutedTag.assemble(muted)) + +fun TagArrayBuilder.publishing(publishing: Boolean) = addUnique(PublishingTag.assemble(publishing)) + +fun TagArrayBuilder.onstage(onstage: Boolean) = addUnique(OnstageTag.assemble(onstage)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/OnstageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/OnstageTag.kt new file mode 100644 index 000000000..b7b8ac1f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/OnstageTag.kt @@ -0,0 +1,52 @@ +/* + * 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.presence.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * `["onstage", "0|1"]` on a kind-10312 presence event. + * + * Distinguishes a "speaker slot holder" (`onstage=1`) from a pure + * audience member (`onstage=0`). The participant grid (Tier 2) + * places onstage peers in the top section and audience members in + * the audience section; the listener counter (#8) counts the + * complement. + * + * Independent of [PublishingTag]: a speaker can be onstage but + * paused (onstage=1, publishing=0), or broadcasting silently + * (onstage=1, publishing=1, muted=1). + */ +class OnstageTag { + companion object Companion { + const val TAG_NAME = "onstage" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] == "1" + } + + fun assemble(onstage: Boolean) = arrayOf(TAG_NAME, if (onstage) "1" else "0") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/PublishingTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/PublishingTag.kt new file mode 100644 index 000000000..45bf97b79 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/PublishingTag.kt @@ -0,0 +1,52 @@ +/* + * 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.presence.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * `["publishing", "0|1"]` on a kind-10312 presence event. + * + * Indicates whether this peer is currently pushing audio to the + * relay (i.e. holds an active speaker slot AND is not muted nor + * paused). Distinct from [MutedTag], which can be true while still + * publishing silence; `publishing=1, muted=1` is "I'm a speaker but + * temporarily muted", and `publishing=0` means no audio packets at + * all. + * + * The participant grid (Tier 2) uses this to render an "active + * waveform" indicator only on peers actually broadcasting. + */ +class PublishingTag { + companion object Companion { + const val TAG_NAME = "publishing" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] == "1" + } + + fun assemble(publishing: Boolean) = arrayOf(TAG_NAME, if (publishing) "1" else "0") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEventTest.kt index 44be9889f..53f76a51f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEventTest.kt @@ -49,6 +49,45 @@ class MeetingRoomPresenceEventTest { assertEquals("${MeetingRoomPresenceEvent.KIND}:$participant:", event.addressTag()) } + @Test + fun parsesPublishingAndOnstageTags() { + val event = + MeetingRoomPresenceEvent( + id = "0".repeat(64), + pubKey = participant, + createdAt = 1_700_000_000L, + tags = + arrayOf( + arrayOf("a", "30312:${"b".repeat(64)}:room"), + arrayOf("hand", "0"), + arrayOf("muted", "1"), + arrayOf("publishing", "1"), + arrayOf("onstage", "1"), + ), + content = "", + sig = "0".repeat(128), + ) + assertEquals(false, event.handRaised()) + assertEquals(true, event.muted()) + assertEquals(true, event.publishing()) + assertEquals(true, event.onstage()) + } + + @Test + fun missingPublishingAndOnstageTagsReturnNull() { + val event = + MeetingRoomPresenceEvent( + id = "0".repeat(64), + pubKey = participant, + createdAt = 1_700_000_000L, + tags = arrayOf(arrayOf("a", "30312:${"b".repeat(64)}:room")), + content = "", + sig = "0".repeat(128), + ) + assertEquals(null, event.publishing()) + assertEquals(null, event.onstage()) + } + @Test fun createAddressKeysByPubKeyOnly() { val a = MeetingRoomPresenceEvent.createAddress(participant)