feat(audio-rooms): scheduled rooms (T1 4b)
Shipped the deferred slice of Tier 1 Step 4: hosts can now
schedule a room for a future time instead of going live
immediately.
Quartz:
StatusTag.STATUS.PLANNED — new enum value alongside OPEN /
PRIVATE / CLOSED. Pre-Lite-03
clients that don't know the value
get null from the parser; the room
renderer's existing fallback handles
that. The amethyst feed renders
PLANNED with the OPEN badge in v1
(a "Scheduled — starts at HH:MM"
chip is a visual follow-up).
StartsTag — `["starts", "<unix-seconds>"]` parser + assembler.
Strict numeric — non-numeric values return null so
a malformed event can't crash the room-list
renderer. Negative values are rejected at assemble.
MeetingSpaceEvent.starts() — accessor.
TagArrayBuilderExt.starts(unixSeconds) — DSL helper.
Amethyst:
CreateAudioRoomViewModel —
onScheduledToggle(scheduled) — flips PLANNED vs OPEN.
onScheduledStartChange(unixSeconds) — picker callback.
FormState.scheduled / .scheduledStartUnix — additive fields.
canSubmit gates on a picked time when scheduled = true.
publishAndBuildLaunchInfo() — emits status=PLANNED +
`["starts", <unix>]` when
scheduled; otherwise the
existing OPEN path runs.
CreateAudioRoomSheet —
Schedule toggle (Material3 Switch) above the picker.
ScheduleStartPicker — OutlinedButton that opens a Material3
DatePickerDialog. The selected date is
saved as 00:00 of that day in the
local time zone (TimePicker stitching
is a follow-up; nostrnests' web UI
does the same date-only flow).
MeetingSpace.kt feed item — added the PLANNED branch to the
exhaustive when so the build passes.
Tests:
StartsTagTest — numeric parse, malformed-rejected, missing /
wrong-name rejection, assemble shape, negative rejected,
STATUS.PLANNED parse round-trip.
This commit is contained in:
+8
@@ -85,6 +85,14 @@ class MeetingSpaceEvent(
|
||||
*/
|
||||
fun background() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.BackgroundTag::parse)
|
||||
|
||||
/**
|
||||
* Scheduled start time as unix seconds, only meaningful when
|
||||
* [status] is [StatusTag.STATUS.PLANNED]. Returns null on
|
||||
* malformed or absent tag — the room-list renderer falls back
|
||||
* to "live now" for status=OPEN/PRIVATE rooms.
|
||||
*/
|
||||
fun starts() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StartsTag::parse)
|
||||
|
||||
fun relays() = tags.mapNotNull(RelayListTag::parse).flatten()
|
||||
|
||||
fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).flatten()
|
||||
|
||||
+6
@@ -47,6 +47,12 @@ fun TagArrayBuilder<MeetingSpaceEvent>.service(url: String) = addUnique(ServiceU
|
||||
|
||||
fun TagArrayBuilder<MeetingSpaceEvent>.endpoint(url: String) = addUnique(EndpointUrlTag.assemble(url))
|
||||
|
||||
fun TagArrayBuilder<MeetingSpaceEvent>.starts(unixSeconds: Long) =
|
||||
addUnique(
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StartsTag
|
||||
.assemble(unixSeconds),
|
||||
)
|
||||
|
||||
fun TagArrayBuilder<MeetingSpaceEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls))
|
||||
|
||||
fun TagArrayBuilder<MeetingSpaceEvent>.participant(
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* `["starts", "<unix-seconds>"]` on a kind-30312 audio-room event
|
||||
* with [StatusTag.STATUS.PLANNED]. Tells subscribers when the host
|
||||
* intends to start the room. nostrnests' room-list uses it to sort
|
||||
* upcoming rooms ahead of live ones.
|
||||
*
|
||||
* Strict numeric parser — non-numeric values return null so a
|
||||
* malformed tag can't crash the room-list renderer.
|
||||
*/
|
||||
class StartsTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "starts"
|
||||
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(unixSeconds: Long): Array<String> {
|
||||
require(unixSeconds >= 0) { "starts: unix seconds must be non-negative, got $unixSeconds" }
|
||||
return arrayOf(TAG_NAME, unixSeconds.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -27,6 +27,8 @@ class StatusTag {
|
||||
enum class STATUS(
|
||||
val code: String,
|
||||
) {
|
||||
/** Scheduled in the future — host hasn't started the room yet. Pairs with a `["starts", <unix>]` tag. */
|
||||
PLANNED("planned"),
|
||||
OPEN("open"),
|
||||
PRIVATE("private"),
|
||||
CLOSED("closed"),
|
||||
@@ -37,9 +39,10 @@ class StatusTag {
|
||||
companion object {
|
||||
fun parse(code: String): STATUS? =
|
||||
when (code) {
|
||||
STATUS.OPEN.code -> STATUS.OPEN
|
||||
STATUS.PRIVATE.code -> STATUS.PRIVATE
|
||||
STATUS.CLOSED.code -> STATUS.CLOSED
|
||||
PLANNED.code -> PLANNED
|
||||
OPEN.code -> OPEN
|
||||
PRIVATE.code -> PRIVATE
|
||||
CLOSED.code -> CLOSED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -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.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class StartsTagTest {
|
||||
@Test
|
||||
fun parsesNumericValue() {
|
||||
assertEquals(1_780_000_000L, StartsTag.parse(arrayOf("starts", "1780000000")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNonNumeric() {
|
||||
// Malformed wire data must NOT crash the room-list renderer.
|
||||
assertNull(StartsTag.parse(arrayOf("starts", "soon")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMissingValue() {
|
||||
assertNull(StartsTag.parse(arrayOf("starts")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongName() {
|
||||
assertNull(StartsTag.parse(arrayOf("ends", "1780000000")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assembleProducesCanonicalShape() {
|
||||
assertEquals(arrayOf("starts", "100").toList(), StartsTag.assemble(100L).toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assembleRejectsNegative() {
|
||||
assertFailsWith<IllegalArgumentException> { StartsTag.assemble(-1) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusEnumIncludesPlanned() {
|
||||
assertEquals(StatusTag.STATUS.PLANNED, StatusTag.STATUS.parse("planned"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user