From f89ab8f1a2321ca7f548ad0e8b3f23ac64e56367 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 23:35:51 +0000 Subject: [PATCH] feat(audio-rooms): scheduled rooms (T1 4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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", ""]` 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", ]` 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. --- .../amethyst/ui/note/types/MeetingSpace.kt | 7 ++ .../audiorooms/create/CreateAudioRoomSheet.kt | 79 +++++++++++++++++++ .../create/CreateAudioRoomViewModel.kt | 32 +++++++- amethyst/src/main/res/values/strings.xml | 2 + .../meetingSpaces/MeetingSpaceEvent.kt | 8 ++ .../meetingSpaces/TagArrayBuilderExt.kt | 6 ++ .../meetingSpaces/tags/StartsTag.kt | 50 ++++++++++++ .../meetingSpaces/tags/StatusTag.kt | 9 ++- .../meetingSpaces/tags/StartsTagTest.kt | 64 +++++++++++++++ 9 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StartsTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StartsTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt index fad48ce65..526e7d193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt @@ -134,6 +134,13 @@ fun RenderMeetingSpaceEventInner( MeetingSpaceClosedFlag() } + MeetingSpaceStatusTag.STATUS.PLANNED -> { + // Planned audio rooms reuse the "open" badge for + // the v1 list; a future commit can render a + // dedicated "Scheduled — starts at HH:MM" chip. + MeetingSpaceOpenFlag() + } + null -> {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt index e860e43e9..ccdf8375a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt @@ -43,8 +43,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization @@ -143,6 +145,27 @@ fun CreateAudioRoomSheet( modifier = Modifier.fillMaxWidth(), ) + // Schedule toggle + start-time picker. Hidden picker when + // toggled off so a "Start now" room doesn't waste vertical + // space on a date row that isn't relevant. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + androidx.compose.material3.Switch( + checked = state.scheduled, + onCheckedChange = viewModel::onScheduledToggle, + ) + Spacer(Modifier.width(8.dp)) + Text(stringRes(R.string.audio_room_create_schedule_toggle)) + } + if (state.scheduled) { + ScheduleStartPicker( + unixSeconds = state.scheduledStartUnix, + onChange = viewModel::onScheduledStartChange, + ) + } + state.error?.let { error -> Text( text = error, @@ -196,3 +219,59 @@ fun CreateAudioRoomSheet( } } } + +/** + * Schedule-start picker. Shows the currently-selected start time + * as a chip; tapping it opens a Material3 DatePickerDialog. Time + * defaults to 00:00 of the selected date — finer granularity is a + * follow-up (Material3's TimePicker is a separate dialog and + * stitching the two needs an extra state machine). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ScheduleStartPicker( + unixSeconds: Long, + onChange: (Long) -> Unit, +) { + var showDialog by remember { mutableStateOf(false) } + val pretty = + if (unixSeconds <= 0L) { + stringRes(R.string.audio_room_create_when) + } else { + val instant = java.util.Date(unixSeconds * 1000L) + java.text.DateFormat + .getDateTimeInstance(java.text.DateFormat.MEDIUM, java.text.DateFormat.SHORT) + .format(instant) + } + androidx.compose.material3.OutlinedButton( + onClick = { showDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(pretty) + } + if (showDialog) { + val initial = if (unixSeconds > 0L) unixSeconds * 1000L else System.currentTimeMillis() + val datePickerState = + androidx.compose.material3.rememberDatePickerState( + initialSelectedDateMillis = initial, + ) + androidx.compose.material3.DatePickerDialog( + onDismissRequest = { showDialog = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { onChange(it / 1000L) } + showDialog = false + }) { + Text(stringRes(R.string.audio_room_create_submit)) + } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringRes(R.string.audio_room_create_cancel)) + } + }, + ) { + androidx.compose.material3.DatePicker(state = datePickerState) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt index 00edea43c..63ff080df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.AudioRoomAc import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.endpoint import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.image +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.starts import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.summary import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag @@ -78,6 +79,16 @@ class CreateAudioRoomViewModel : ViewModel() { fun onImageUrlChange(value: String) = _state.update { it.copy(imageUrl = value.trim(), error = null) } + /** + * Toggle scheduled-vs-now mode. When [scheduled] is true the + * sheet shows a date / time picker and the published event uses + * `status=PLANNED` + `["starts", ]`. When false, the + * existing `status=OPEN` "live now" path runs. + */ + fun onScheduledToggle(scheduled: Boolean) = _state.update { it.copy(scheduled = scheduled, error = null) } + + fun onScheduledStartChange(unixSeconds: Long) = _state.update { it.copy(scheduledStartUnix = unixSeconds, error = null) } + /** * Build the kind-30312 event, sign + broadcast it, and return the * launch info the sheet needs to start [AudioRoomActivity]. Returns @@ -106,17 +117,26 @@ class CreateAudioRoomViewModel : ViewModel() { return null } + if (current.scheduled && current.scheduledStartUnix <= 0L) { + _state.update { it.copy(error = "Pick a start time for the scheduled room.") } + return null + } + _state.update { it.copy(isPublishing = true, error = null) } val accountModel = account.account val hostPubkey = accountModel.userProfile().pubkeyHex + val targetStatus = if (current.scheduled) StatusTag.STATUS.PLANNED else StatusTag.STATUS.OPEN val template = MeetingSpaceEvent.build( room = current.roomName.trim(), - status = StatusTag.STATUS.OPEN, + status = targetStatus, service = service, host = ParticipantTag(hostPubkey, null, ROLE.HOST.code, null), ) { endpoint(endpoint) + if (current.scheduled) { + starts(current.scheduledStartUnix) + } current.summary .trim() .takeIf { it.isNotBlank() } @@ -161,9 +181,17 @@ class CreateAudioRoomViewModel : ViewModel() { val imageUrl: String, val isPublishing: Boolean, val error: String?, + /** When true, publish as `status=PLANNED` + `["starts", ]`. */ + val scheduled: Boolean = false, + /** Unix seconds of the scheduled start; 0 = not yet picked. */ + val scheduledStartUnix: Long = 0L, ) { val canSubmit: Boolean - get() = roomName.isNotBlank() && serviceUrl.isNotBlank() && endpointUrl.isNotBlank() + get() = + roomName.isNotBlank() && + serviceUrl.isNotBlank() && + endpointUrl.isNotBlank() && + (!scheduled || scheduledStartUnix > 0L) companion object { fun defaults() = diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4e5f4d24c..0a1301931 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -556,6 +556,8 @@ Cover image URL (optional) Cancel Start space + Schedule for later + Pick a start time Audio-room servers Choose which MoQ host servers Amethyst publishes your audio rooms to. The first entry is used by default when you start a new space. Your servers 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 ed9355a13..12e74ba72 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 @@ -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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt index eca9aa1c9..ab0ea29d7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/TagArrayBuilderExt.kt @@ -47,6 +47,12 @@ fun TagArrayBuilder.service(url: String) = addUnique(ServiceU fun TagArrayBuilder.endpoint(url: String) = addUnique(EndpointUrlTag.assemble(url)) +fun TagArrayBuilder.starts(unixSeconds: Long) = + addUnique( + com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StartsTag + .assemble(unixSeconds), + ) + fun TagArrayBuilder.relays(urls: List) = addUnique(RelayListTag.assemble(urls)) fun TagArrayBuilder.participant( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StartsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StartsTag.kt new file mode 100644 index 000000000..77414bc78 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StartsTag.kt @@ -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", ""]` 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): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(unixSeconds: Long): Array { + require(unixSeconds >= 0) { "starts: unix seconds must be non-negative, got $unixSeconds" } + return arrayOf(TAG_NAME, unixSeconds.toString()) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt index 88055fa69..49b15c2bd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt @@ -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", ]` 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 } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StartsTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StartsTagTest.kt new file mode 100644 index 000000000..e559f99bd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StartsTagTest.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.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 { StartsTag.assemble(-1) } + } + + @Test + fun statusEnumIncludesPlanned() { + assertEquals(StatusTag.STATUS.PLANNED, StatusTag.STATUS.parse("planned")) + } +}