Merge pull request #2646 from vitorpamplona/claude/fix-nest-room-connection-H48ad
Align nests servers with NIP-53 spec: separate relay and auth URLs
This commit is contained in:
@@ -3087,7 +3087,7 @@ class Account(
|
||||
|
||||
suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
|
||||
|
||||
suspend fun sendNestsServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(nestsServers.saveNestsServersList(servers))
|
||||
suspend fun sendNestsServersList(servers: List<com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServer>) = sendMyPublicAndPrivateOutbox(nestsServers.saveNestsServersList(servers))
|
||||
|
||||
suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets))
|
||||
|
||||
|
||||
+10
-8
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServer
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -40,15 +41,16 @@ import kotlinx.coroutines.flow.stateIn
|
||||
* [BlossomServerListState] for the nests use case.
|
||||
*
|
||||
* Surfaces:
|
||||
* - [flow] — current `List<String>` of saved server base URLs
|
||||
* - [flow] — current `List<NestsServer>` of saved (relay, auth) pairs
|
||||
* - [getNestsServersListFlow] — reactive `StateFlow<NoteState>` for
|
||||
* downstream UI to recompose on event arrivals
|
||||
* - [saveNestsServersList] — build + sign a new replaceable kind 10112
|
||||
* event (preserving prior tags' alt etc.)
|
||||
*
|
||||
* The list is consumed by `CreateNestViewModel` to default the
|
||||
* "MoQ service URL" / "MoQ endpoint URL" fields when starting a new
|
||||
* space, and by the Settings screen for edit / add / remove.
|
||||
* Each saved entry carries the **two** URLs the kind-30312 event needs:
|
||||
* the moq-relay WebTransport endpoint and the moq-auth sidecar base.
|
||||
* They live on different hosts in the deployed nostrnests reference, so
|
||||
* the pair MUST stay together.
|
||||
*/
|
||||
class NestsServerListState(
|
||||
val signer: NostrSigner,
|
||||
@@ -64,12 +66,12 @@ class NestsServerListState(
|
||||
|
||||
fun getNestsServersList(): NestsServersEvent? = nestsListNote.event as? NestsServersEvent
|
||||
|
||||
fun normalizeServers(note: Note): List<String> {
|
||||
fun normalizeServers(note: Note): List<NestsServer> {
|
||||
val event = note.event as? NestsServersEvent
|
||||
return event?.servers() ?: emptyList()
|
||||
}
|
||||
|
||||
val flow: StateFlow<List<String>> =
|
||||
val flow: StateFlow<List<NestsServer>> =
|
||||
getNestsServersListFlow()
|
||||
.map {
|
||||
normalizeServers(it.note)
|
||||
@@ -80,7 +82,7 @@ class NestsServerListState(
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
suspend fun saveNestsServersList(servers: List<String>): NestsServersEvent {
|
||||
suspend fun saveNestsServersList(servers: List<NestsServer>): NestsServersEvent {
|
||||
val serverList = getNestsServersList()
|
||||
|
||||
return if (serverList != null && serverList.tags.isNotEmpty()) {
|
||||
@@ -91,7 +93,7 @@ class NestsServerListState(
|
||||
)
|
||||
} else {
|
||||
NestsServersEvent.createFromScratch(
|
||||
relays = servers,
|
||||
servers = servers,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
|
||||
+119
-18
@@ -31,15 +31,21 @@ import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -50,19 +56,22 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServerEditField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
|
||||
|
||||
/**
|
||||
@@ -70,9 +79,12 @@ import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEve
|
||||
* MoQ host servers — kind 10112 [NestsServersEvent].
|
||||
*
|
||||
* Mirrors [AllMediaServersScreen] — top bar with Cancel / Save, list
|
||||
* of saved servers with delete buttons, edit-field row to add a new
|
||||
* one, and a "Use defaults" row for one-tap inclusion of
|
||||
* `nostrnests.com`.
|
||||
* of saved servers with delete buttons, edit-fields to add a new
|
||||
* (relay, auth) pair, and a "Use defaults" row for one-tap inclusion
|
||||
* of `nostrnests.com`. Each server entry is two URLs because the
|
||||
* deployed nostrnests reference puts the moq-relay (WebTransport) and
|
||||
* the moq-auth (JWT mint) sidecar on different hosts; collapsing them
|
||||
* to a single URL breaks JWT minting.
|
||||
*
|
||||
* Reachable from `Settings → Audio-room servers`.
|
||||
*/
|
||||
@@ -161,19 +173,19 @@ private fun NestsServersBody(viewModel: NestsServersViewModel) {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(servers, key = { _, s -> "saved-${s.baseUrl}" }) { _, entry ->
|
||||
NestsServerEntry(
|
||||
itemsIndexed(servers, key = { _, s -> "saved-${s.relay}" }) { _, entry ->
|
||||
NestsServerRow(
|
||||
server = entry,
|
||||
isAmethystDefault = false,
|
||||
onAction = { viewModel.removeServer(entry.baseUrl) },
|
||||
onAction = { viewModel.removeServer(entry.relay) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
MediaServerEditField(label = R.string.nests_servers_add_field) { url ->
|
||||
viewModel.addServer(url)
|
||||
NestsServerPairEditField { relay, auth ->
|
||||
viewModel.addServer(relay, auth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,26 +196,26 @@ private fun NestsServersBody(viewModel: NestsServersViewModel) {
|
||||
modifier = SettingsCategorySpacingModifier,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.addServerList(DEFAULT_NESTS_SERVERS.map { it.baseUrl }) },
|
||||
onClick = { viewModel.addServerList(DEFAULT_NESTS_SERVERS) },
|
||||
) {
|
||||
Text(text = stringRes(id = R.string.nests_servers_use_defaults))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(DEFAULT_NESTS_SERVERS, key = { _, s -> "proposed-${s.baseUrl}" }) { _, entry ->
|
||||
NestsServerEntry(
|
||||
itemsIndexed(DEFAULT_NESTS_SERVERS, key = { _, s -> "proposed-${s.relay}" }) { _, entry ->
|
||||
NestsServerRow(
|
||||
server = entry,
|
||||
isAmethystDefault = true,
|
||||
onAction = { viewModel.addServer(entry.baseUrl) },
|
||||
onAction = { viewModel.addServer(entry.relay, entry.auth) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NestsServerEntry(
|
||||
server: NestsServer,
|
||||
private fun NestsServerRow(
|
||||
server: NestsServerEntry,
|
||||
isAmethystDefault: Boolean,
|
||||
onAction: () -> Unit,
|
||||
) {
|
||||
@@ -222,7 +234,12 @@ private fun NestsServerEntry(
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = server.baseUrl,
|
||||
text = "${stringRes(R.string.nests_servers_relay_label)}: ${server.relay}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
Text(
|
||||
text = "${stringRes(R.string.nests_servers_auth_label)}: ${server.auth}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
@@ -243,12 +260,96 @@ private fun NestsServerEntry(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-field editor for a (relay, auth) pair plus an Add button. The
|
||||
* Add button is enabled only once both URLs parse — we don't try to
|
||||
* auto-derive the auth URL on input even though [NestsServersEvent.deriveAuthUrl]
|
||||
* exists; making the user paste both forces them to think about which
|
||||
* deployment they're on, which avoids the moq.* / moq-auth.* host
|
||||
* collision that produced the 4443-TCP connection bug.
|
||||
*/
|
||||
@Composable
|
||||
private fun NestsServerPairEditField(onAdd: (relay: String, auth: String) -> Unit) {
|
||||
var relay by remember { mutableStateOf("") }
|
||||
var auth by remember { mutableStateOf("") }
|
||||
val canSubmit by
|
||||
remember {
|
||||
derivedStateOf {
|
||||
relay.isNotBlank() &&
|
||||
auth.isNotBlank() &&
|
||||
HttpUrlFormatter.isValidUrl(relay) &&
|
||||
HttpUrlFormatter.isValidUrl(auth)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(Size10dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.nests_servers_add_relay_field)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = relay,
|
||||
onValueChange = { relay = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "https://moq.example.com:4443",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.nests_servers_add_auth_field)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = auth,
|
||||
onValueChange = { auth = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "https://moq-auth.example.com",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (canSubmit) {
|
||||
onAdd(
|
||||
HttpUrlFormatter.normalize(relay),
|
||||
HttpUrlFormatter.normalize(auth),
|
||||
)
|
||||
relay = ""
|
||||
auth = ""
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
enabled = canSubmit,
|
||||
) {
|
||||
Text(stringRes(R.string.nests_servers_add_pair_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in suggestion list shown under "Recommended servers". Today
|
||||
* just the public `nostrnests.com` deployment; add new entries here
|
||||
* as community-run moq-rs / moq-auth instances come online.
|
||||
*
|
||||
* Each entry carries the relay and auth URLs side-by-side. They are
|
||||
* stored verbatim into the kind-10112 `["server", relay, auth]` tag
|
||||
* and pre-fill the kind-30312 `streaming` and `auth` tags when the
|
||||
* user starts a room.
|
||||
*/
|
||||
val DEFAULT_NESTS_SERVERS: List<NestsServer> =
|
||||
val DEFAULT_NESTS_SERVERS: List<NestsServerEntry> =
|
||||
listOf(
|
||||
NestsServer(name = "nostrnests.com", baseUrl = "https://moq.nostrnests.com:4443"),
|
||||
NestsServerEntry(
|
||||
name = "nostrnests.com",
|
||||
relay = "https://moq.nostrnests.com:4443",
|
||||
auth = "https://moq-auth.nostrnests.com",
|
||||
),
|
||||
)
|
||||
|
||||
+58
-25
@@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomServersViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServer
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
|
||||
import com.vitorpamplona.quartz.utils.Rfc3986
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -33,10 +34,12 @@ import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Edit-buffer for the user's audio-room (NIP-53 / nests) MoQ server
|
||||
* list. Mirror of [BlossomServersViewModel] scoped down to a plain
|
||||
* `List<String>` of base URLs since nests servers don't have a
|
||||
* "selected default" concept (clients pick the first entry when
|
||||
* starting a new space).
|
||||
* list. Mirror of [BlossomServersViewModel] but every entry carries
|
||||
* **two** URLs (the moq-relay WebTransport endpoint and the moq-auth
|
||||
* sidecar base URL) — those are the two values that end up in the
|
||||
* kind-30312 `streaming` and `auth` tags. The deployed nostrnests
|
||||
* reference puts these on different hosts, so we cannot collapse them
|
||||
* into a single URL.
|
||||
*
|
||||
* The flow:
|
||||
* 1. [init] binds the [AccountViewModel].
|
||||
@@ -52,7 +55,7 @@ class NestsServersViewModel : ViewModel() {
|
||||
private lateinit var accountViewModel: AccountViewModel
|
||||
private lateinit var account: Account
|
||||
|
||||
private val _servers = MutableStateFlow<List<NestsServer>>(emptyList())
|
||||
private val _servers = MutableStateFlow<List<NestsServerEntry>>(emptyList())
|
||||
val servers = _servers.asStateFlow()
|
||||
private var isModified = false
|
||||
|
||||
@@ -67,31 +70,41 @@ class NestsServersViewModel : ViewModel() {
|
||||
isModified = false
|
||||
val current = account.nestsServers.flow.value
|
||||
_servers.update {
|
||||
current.map { url -> NestsServer(displayHostName(url), url) }
|
||||
current.map { it.toEntry() }
|
||||
}
|
||||
}
|
||||
|
||||
fun addServer(url: String) {
|
||||
val normalised =
|
||||
try {
|
||||
Rfc3986.normalize(url.trim())
|
||||
} catch (_: Throwable) {
|
||||
url.trim()
|
||||
}
|
||||
if (normalised.isBlank()) return
|
||||
val entry = NestsServer(displayHostName(normalised), normalised)
|
||||
if (_servers.value.any { it.baseUrl == entry.baseUrl }) return
|
||||
/**
|
||||
* Add a server pair. Both URLs go on the wire as the third element
|
||||
* of the kind-10112 `server` tag (relay first, auth second). The
|
||||
* recommended-list "Add" button supplies both; the manual "Add a
|
||||
* server" field also asks for both.
|
||||
*/
|
||||
fun addServer(
|
||||
relay: String,
|
||||
auth: String,
|
||||
) {
|
||||
val normalisedRelay = normalize(relay)
|
||||
val normalisedAuth = normalize(auth)
|
||||
if (normalisedRelay.isBlank() || normalisedAuth.isBlank()) return
|
||||
val entry =
|
||||
NestsServerEntry(
|
||||
name = displayHostName(normalisedRelay),
|
||||
relay = normalisedRelay,
|
||||
auth = normalisedAuth,
|
||||
)
|
||||
if (_servers.value.any { it.relay == entry.relay }) return
|
||||
_servers.update { it + entry }
|
||||
isModified = true
|
||||
}
|
||||
|
||||
fun addServerList(urls: List<String>) {
|
||||
urls.forEach { addServer(it) }
|
||||
fun addServerList(servers: List<NestsServerEntry>) {
|
||||
servers.forEach { addServer(it.relay, it.auth) }
|
||||
}
|
||||
|
||||
fun removeServer(baseUrl: String) {
|
||||
fun removeServer(relay: String) {
|
||||
val before = _servers.value
|
||||
val after = before.filterNot { it.baseUrl == baseUrl }
|
||||
val after = before.filterNot { it.relay == relay }
|
||||
if (after.size != before.size) {
|
||||
_servers.update { after }
|
||||
isModified = true
|
||||
@@ -108,25 +121,45 @@ class NestsServersViewModel : ViewModel() {
|
||||
fun save() {
|
||||
if (!isModified) return
|
||||
accountViewModel.launchSigner {
|
||||
account.sendNestsServersList(_servers.value.map { it.baseUrl })
|
||||
account.sendNestsServersList(_servers.value.map { NestsServer(relay = it.relay, auth = it.auth) })
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalize(url: String): String =
|
||||
try {
|
||||
Rfc3986.normalize(url.trim())
|
||||
} catch (_: Throwable) {
|
||||
url.trim()
|
||||
}
|
||||
|
||||
private fun displayHostName(url: String): String =
|
||||
try {
|
||||
Rfc3986.host(url).removePrefix("moq.").removePrefix("nests.")
|
||||
Rfc3986
|
||||
.host(url)
|
||||
.removePrefix("moq-auth.")
|
||||
.removePrefix("moq.")
|
||||
.removePrefix("nests.")
|
||||
} catch (_: Throwable) {
|
||||
url
|
||||
}
|
||||
|
||||
private fun NestsServer.toEntry() =
|
||||
NestsServerEntry(
|
||||
name = displayHostName(relay),
|
||||
relay = relay,
|
||||
auth = auth,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-friendly entry for [NestsServersViewModel].
|
||||
* - [name] — short host label (e.g. `nostrnests.com`)
|
||||
* - [baseUrl] — exact bytes that go on the wire (`https://moq.nostrnests.com`)
|
||||
* - [relay] — moq-relay WebTransport URL (`https://moq.nostrnests.com:4443`)
|
||||
* - [auth] — moq-auth sidecar base URL (`https://moq-auth.nostrnests.com`)
|
||||
*/
|
||||
data class NestsServer(
|
||||
data class NestsServerEntry(
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val relay: String,
|
||||
val auth: String,
|
||||
)
|
||||
|
||||
@@ -125,7 +125,7 @@ fun RenderMeetingSpaceEventInner(
|
||||
|
||||
CrossfadeIfEnabled(targetState = status, label = "MeetingSpaceStatus", accountViewModel = accountViewModel) {
|
||||
when (it) {
|
||||
MeetingSpaceStatusTag.STATUS.OPEN -> {
|
||||
MeetingSpaceStatusTag.STATUS.LIVE -> {
|
||||
MeetingSpaceOpenFlag()
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ fun RenderMeetingSpaceEventInner(
|
||||
MeetingSpacePrivateFlag()
|
||||
}
|
||||
|
||||
MeetingSpaceStatusTag.STATUS.CLOSED -> {
|
||||
MeetingSpaceStatusTag.STATUS.ENDED -> {
|
||||
MeetingSpaceClosedFlag()
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ fun RenderMeetingSpaceEventInner(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
if (status == MeetingSpaceStatusTag.STATUS.CLOSED) {
|
||||
if (status == MeetingSpaceStatusTag.STATUS.ENDED) {
|
||||
recording?.let {
|
||||
ListenToRecordingButton(url = it, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
+2
-2
@@ -159,9 +159,9 @@ open class DiscoverLiveFeedFilter(
|
||||
|
||||
is MeetingSpaceEvent -> {
|
||||
when (event.status()) {
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.OPEN -> 2
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.LIVE -> 2
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.PRIVATE -> 1
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.CLOSED -> 0
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.ENDED -> 0
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -140,9 +140,9 @@ fun RenderLiveActivityThumb(
|
||||
participants = noteEvent.participants().toImmutableList(),
|
||||
status =
|
||||
when (noteEvent.status()) {
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.OPEN -> StatusTag.STATUS.LIVE
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.LIVE -> StatusTag.STATUS.LIVE
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.PRIVATE -> StatusTag.STATUS.PLANNED
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.CLOSED -> StatusTag.STATUS.ENDED
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.ENDED -> StatusTag.STATUS.ENDED
|
||||
else -> null
|
||||
},
|
||||
starts = null,
|
||||
|
||||
+1
-1
@@ -239,7 +239,7 @@ class HomeLiveFilter(
|
||||
LocalCache.getAddressableNoteIfExists(roomAddress)?.event as? MeetingSpaceEvent
|
||||
?: return false
|
||||
val status = room.status()
|
||||
return status == MeetingSpaceStatusTag.STATUS.OPEN ||
|
||||
return status == MeetingSpaceStatusTag.STATUS.LIVE ||
|
||||
status == MeetingSpaceStatusTag.STATUS.PRIVATE
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ private suspend fun checkChannelIsOnline(
|
||||
// the red dot whenever the room is OPEN/PRIVATE.
|
||||
if (channel.address.kind == MeetingSpaceEvent.KIND) {
|
||||
val room = LocalCache.getAddressableNoteIfExists(channel.address)?.event as? MeetingSpaceEvent
|
||||
room?.status() == MeetingSpaceStatusTag.STATUS.OPEN ||
|
||||
room?.status() == MeetingSpaceStatusTag.STATUS.LIVE ||
|
||||
room?.status() == MeetingSpaceStatusTag.STATUS.PRIVATE
|
||||
} else {
|
||||
// Check if streaming URL is online, fall back to relay check
|
||||
|
||||
+2
-2
@@ -159,9 +159,9 @@ class LiveStreamsFeedFilter(
|
||||
|
||||
is MeetingSpaceEvent -> {
|
||||
when (event.status()) {
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.OPEN -> 2
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.LIVE -> 2
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.PRIVATE -> 1
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.CLOSED -> 0
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.ENDED -> 0
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -339,11 +339,11 @@ fun RenderLiveSpacesThumb(
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
CrossfadeIfEnabled(targetState = card.status, accountViewModel = accountViewModel) {
|
||||
when (it) {
|
||||
StatusTag.STATUS.OPEN -> {
|
||||
StatusTag.STATUS.LIVE -> {
|
||||
RenderLiveOrEndedFromPresence(card.id, accountViewModel)
|
||||
}
|
||||
|
||||
StatusTag.STATUS.CLOSED -> {
|
||||
StatusTag.STATUS.ENDED -> {
|
||||
EndedFlag()
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -97,7 +97,7 @@ fun NestsScreen(
|
||||
FabBottomBarPadded(nav) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (nestsServers.any { it.startsWith("http") }) {
|
||||
if (nestsServers.any { it.relay.startsWith("http") && it.auth.startsWith("http") }) {
|
||||
showCreateSheet = true
|
||||
} else {
|
||||
showSetupDialog = true
|
||||
@@ -137,14 +137,26 @@ fun NestsScreen(
|
||||
|
||||
if (showSetupDialog) {
|
||||
SetUpAudioServerDialog(
|
||||
defaultUrl = CreateNestViewModel.DEFAULT_SERVICE_URL,
|
||||
defaultUrl = CreateNestViewModel.DEFAULT_ENDPOINT_URL,
|
||||
onDismiss = { showSetupDialog = false },
|
||||
onConfirm = {
|
||||
showSetupDialog = false
|
||||
accountViewModel.launchSigner {
|
||||
try {
|
||||
// Seed the kind-10112 list with the public nostrnests
|
||||
// (relay, auth) pair. We can't derive the auth host
|
||||
// from the relay URL reliably for community-run
|
||||
// deployments, so the dialog hardcodes the public
|
||||
// pair; users with their own deployment use the
|
||||
// Settings → Audio-room servers screen instead.
|
||||
accountViewModel.account.sendNestsServersList(
|
||||
listOf(CreateNestViewModel.DEFAULT_SERVICE_URL),
|
||||
listOf(
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.nestsServers
|
||||
.NestsServer(
|
||||
relay = CreateNestViewModel.DEFAULT_ENDPOINT_URL,
|
||||
auth = CreateNestViewModel.DEFAULT_SERVICE_URL,
|
||||
),
|
||||
),
|
||||
)
|
||||
showCreateSheet = true
|
||||
} catch (_: Throwable) {
|
||||
|
||||
+14
-6
@@ -74,12 +74,11 @@ class CreateNestViewModel : ViewModel() {
|
||||
// first-time use flows naturally from the Settings screen. If
|
||||
// the list is empty (or the user hasn't published one yet), keep
|
||||
// the nostrnests.com defaults already in [FormState.defaults].
|
||||
val savedFirst =
|
||||
val first =
|
||||
accountViewModel.account.nestsServers.flow.value
|
||||
.firstOrNull()
|
||||
?.takeIf { it.startsWith("http") }
|
||||
if (savedFirst != null) {
|
||||
_state.update { it.copy(serviceUrl = savedFirst, endpointUrl = savedFirst) }
|
||||
if (first != null && first.relay.startsWith("http") && first.auth.startsWith("http")) {
|
||||
_state.update { it.copy(serviceUrl = first.auth, endpointUrl = first.relay) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +248,7 @@ class CreateNestViewModel : ViewModel() {
|
||||
_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 targetStatus = if (current.scheduled) StatusTag.STATUS.PLANNED else StatusTag.STATUS.LIVE
|
||||
val template =
|
||||
MeetingSpaceEvent.build(
|
||||
room = current.roomName.trim(),
|
||||
@@ -355,8 +354,17 @@ class CreateNestViewModel : ViewModel() {
|
||||
* Public nostrnests deployment — a blank form produces a working
|
||||
* room here. Users can edit either field to point at their own
|
||||
* moq-auth / moq-relay pair.
|
||||
*
|
||||
* The auth sidecar (moq-auth) and the WebTransport relay
|
||||
* (moq-rs) live on different hosts: moq-auth speaks regular
|
||||
* HTTPS on :443 to mint JWTs; moq.nostrnests.com:4443 is the
|
||||
* QUIC/WebTransport endpoint and does NOT accept TCP. Don't
|
||||
* collapse them back into a single URL — OkHttp can't speak
|
||||
* HTTP/3, so a TCP POST to :4443 will hang and fail. Both
|
||||
* defaults are emitted on the kind-30312 event as the deployed
|
||||
* `streaming` (relay) and `auth` (sidecar) tags.
|
||||
*/
|
||||
const val DEFAULT_SERVICE_URL: String = "https://moq.nostrnests.com:4443"
|
||||
const val DEFAULT_SERVICE_URL: String = "https://moq-auth.nostrnests.com"
|
||||
const val DEFAULT_ENDPOINT_URL: String = "https://moq.nostrnests.com:4443"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -168,7 +168,7 @@ class NestsFeedFilter(
|
||||
presenceCutoff: Long,
|
||||
): Boolean {
|
||||
val status = event.status()
|
||||
if (status != StatusTag.STATUS.OPEN && status != StatusTag.STATUS.PRIVATE) return true
|
||||
if (status != StatusTag.STATUS.LIVE && status != StatusTag.STATUS.PRIVATE) return true
|
||||
if (event.createdAt > presenceCutoff) return true
|
||||
|
||||
val channel = LocalCache.getLiveActivityChannelIfExists(event.address()) ?: return false
|
||||
@@ -231,9 +231,9 @@ class NestsFeedFilter(
|
||||
|
||||
private fun convertStatusToOrder(event: MeetingSpaceEvent?): Int =
|
||||
when (event?.status()) {
|
||||
StatusTag.STATUS.OPEN -> 2
|
||||
StatusTag.STATUS.LIVE -> 2
|
||||
StatusTag.STATUS.PRIVATE -> 1
|
||||
StatusTag.STATUS.CLOSED -> 0
|
||||
StatusTag.STATUS.ENDED -> 0
|
||||
else -> 0
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -42,7 +42,7 @@ import kotlinx.coroutines.flow.update
|
||||
* tag so the relay treats it as a replacement of the original.
|
||||
*
|
||||
* Closing the room ([closeRoom]) is the same path with
|
||||
* [StatusTag.STATUS.CLOSED] forced regardless of the form fields,
|
||||
* [StatusTag.STATUS.ENDED] forced regardless of the form fields,
|
||||
* so a host can close without editing anything else.
|
||||
*
|
||||
* Participants are preserved verbatim — re-publishing must NOT lose
|
||||
@@ -92,10 +92,10 @@ class EditNestViewModel : ViewModel() {
|
||||
fun setServiceUrl(value: String) = _state.update { it.copy(serviceUrl = value) }
|
||||
|
||||
/** Publish the edited room. Returns `true` on success. */
|
||||
suspend fun save(): Boolean = republish(StatusTag.STATUS.OPEN)
|
||||
suspend fun save(): Boolean = republish(StatusTag.STATUS.LIVE)
|
||||
|
||||
/** Close the room (host only). Republishes with status=CLOSED. */
|
||||
suspend fun closeRoom(): Boolean = republish(StatusTag.STATUS.CLOSED)
|
||||
suspend fun closeRoom(): Boolean = republish(StatusTag.STATUS.ENDED)
|
||||
|
||||
private suspend fun republish(targetStatus: StatusTag.STATUS): Boolean {
|
||||
val avm = account ?: return false
|
||||
|
||||
+20
-5
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -43,9 +45,12 @@ import kotlinx.coroutines.launch
|
||||
* onstage transition, then every [PRESENCE_REFRESH_MS]. Mute
|
||||
* and publishing flags are intentionally NOT keys: every mute
|
||||
* toggle would otherwise round-trip a presence emit (audit
|
||||
* Android #11). The next heartbeat picks them up within 30 s,
|
||||
* which is well within the user's "did the peer see my mute"
|
||||
* tolerance.
|
||||
* Android #11). The heartbeat reads them via
|
||||
* [rememberUpdatedState] so each refresh picks up the latest
|
||||
* value WITHOUT cancelling and restarting the loop — without
|
||||
* that wrapper the captured-at-launch values stay frozen and
|
||||
* a 30 s refresh would overwrite a fresh mute toggle with the
|
||||
* pre-toggle state, hiding the avatar's mute icon.
|
||||
*
|
||||
* - **Debounce** — after a mute toggle, wait
|
||||
* [PRESENCE_DEBOUNCE_MS] for further changes before sending a
|
||||
@@ -77,11 +82,21 @@ internal fun NestPresencePublisher(
|
||||
val publishingTag: Boolean = ui.publishingNow
|
||||
val onstageTag: Boolean = ui.onStageNow
|
||||
|
||||
// Latest snapshots for the heartbeat. Without these, the
|
||||
// LaunchedEffect captures the values from FIRST composition; the
|
||||
// 30 s refresh would then overwrite a recent mute toggle with the
|
||||
// pre-toggle state, which presented as "the mute icon disappeared
|
||||
// a few seconds after I muted, but the mic is still hot."
|
||||
val currentHandRaised by rememberUpdatedState(handRaised)
|
||||
val currentMicMuted by rememberUpdatedState(micMutedTag)
|
||||
val currentPublishing by rememberUpdatedState(publishingTag)
|
||||
val currentOnstage by rememberUpdatedState(onstageTag)
|
||||
|
||||
LaunchedEffect(event.address().toValue(), handRaised, onstageTag) {
|
||||
publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag)
|
||||
publishPresence(account, event, currentHandRaised, currentMicMuted, currentPublishing, currentOnstage)
|
||||
while (isActive) {
|
||||
delay(PRESENCE_REFRESH_MS)
|
||||
publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag)
|
||||
publishPresence(account, event, currentHandRaised, currentMicMuted, currentPublishing, currentOnstage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -163,7 +163,7 @@ private fun NestJoinCardContent(
|
||||
}
|
||||
Spacer(StdHorzSpacer)
|
||||
when (status) {
|
||||
StatusTag.STATUS.OPEN -> {
|
||||
StatusTag.STATUS.LIVE -> {
|
||||
MeetingSpaceOpenFlag()
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ private fun NestJoinCardContent(
|
||||
MeetingSpacePrivateFlag()
|
||||
}
|
||||
|
||||
StatusTag.STATUS.CLOSED -> {
|
||||
StatusTag.STATUS.ENDED -> {
|
||||
MeetingSpaceClosedFlag()
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -77,7 +77,7 @@ internal object RoomParticipantActions {
|
||||
} else {
|
||||
all + ParticipantTag(targetPubkey, null, newRole.code, null)
|
||||
}
|
||||
return rebuild(original, mutated, original.status() ?: StatusTag.STATUS.OPEN)
|
||||
return rebuild(original, mutated, original.status() ?: StatusTag.STATUS.LIVE)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +109,7 @@ internal object RoomParticipantActions {
|
||||
val target = all.firstOrNull { it.pubKey == targetPubkey } ?: return null
|
||||
if (target.role.equals(ROLE.HOST.code, ignoreCase = true)) return null
|
||||
val remaining = all.filterNot { it.pubKey == targetPubkey }
|
||||
return rebuild(original, remaining, original.status() ?: StatusTag.STATUS.OPEN)
|
||||
return rebuild(original, remaining, original.status() ?: StatusTag.STATUS.LIVE)
|
||||
}
|
||||
|
||||
private fun rebuild(
|
||||
|
||||
+15
-11
@@ -21,8 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -30,7 +32,6 @@ import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -51,6 +52,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -422,16 +424,18 @@ private fun NestTabRow(
|
||||
selected = tab == selectedTab,
|
||||
onClick = { onSelect(tab) },
|
||||
text = {
|
||||
if (count > 0) {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
Badge { Text(text = count.toString()) }
|
||||
},
|
||||
) {
|
||||
Text(text = label)
|
||||
}
|
||||
} else {
|
||||
// Lay the label and the count badge side-by-side so
|
||||
// the count reads as "Audience 3" instead of being
|
||||
// overlapped on top of the label by `BadgedBox`'s
|
||||
// default top-end placement (audit Android #43).
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(text = label)
|
||||
if (count > 0) {
|
||||
Badge { Text(text = count.toString()) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -552,7 +556,7 @@ private suspend fun closeMeetingSpace(
|
||||
)
|
||||
return try {
|
||||
val template =
|
||||
EditNestViewModel.buildEditTemplate(event, verbatim, StatusTag.STATUS.CLOSED)
|
||||
EditNestViewModel.buildEditTemplate(event, verbatim, StatusTag.STATUS.ENDED)
|
||||
accountViewModel.account.signAndComputeBroadcast(template)
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
|
||||
+13
-2
@@ -355,10 +355,15 @@ private fun MemberCell(
|
||||
// Both color and width crossfade so going idle → speaking → idle
|
||||
// doesn't snap; the color animates from Transparent through the
|
||||
// speaking green and the width tracks the live peak amplitude.
|
||||
// A speaker on stage with mic-mute on emits kind-10312 with
|
||||
// `publishing=0, muted=1` (deployed nostrnests semantics — see
|
||||
// EGG-04 / NestRoomPresencePublisher), so the muted ring must NOT
|
||||
// gate on `publishing`; muting would otherwise hide the very
|
||||
// indicator it was supposed to surface.
|
||||
val targetRingColor =
|
||||
when {
|
||||
isSpeaking -> NEST_SPEAKING_COLOR
|
||||
showMicBadge && member.publishing && member.muted == true -> mutedRingColor
|
||||
showMicBadge && member.muted == true -> mutedRingColor
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val targetRingWidth =
|
||||
@@ -445,7 +450,13 @@ private fun MemberCell(
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
if (showMicBadge && member.publishing) {
|
||||
// Show the mic badge for any on-stage speaker that has
|
||||
// an audio state to surface — currently broadcasting
|
||||
// (`publishing=1`) OR mic-muted (`muted=1, publishing=0`).
|
||||
// Gating only on `publishing` would hide the muted icon
|
||||
// the moment the user mutes, which is exactly when it's
|
||||
// supposed to appear.
|
||||
if (showMicBadge && (member.publishing || member.muted == true)) {
|
||||
MicStateBadge(
|
||||
isSpeaking = isSpeaking,
|
||||
isMuted = member.muted == true,
|
||||
|
||||
@@ -610,6 +610,11 @@
|
||||
<string name="nests_servers_my_section">Your servers</string>
|
||||
<string name="nests_servers_my_explainer">Saved as a kind-10112 replaceable event so other clients can read your preference.</string>
|
||||
<string name="nests_servers_add_field">Add a nest server</string>
|
||||
<string name="nests_servers_add_relay_field">Relay (WebTransport) URL</string>
|
||||
<string name="nests_servers_add_auth_field">Auth (JWT mint) URL</string>
|
||||
<string name="nests_servers_add_pair_button">Add</string>
|
||||
<string name="nests_servers_relay_label">Relay</string>
|
||||
<string name="nests_servers_auth_label">Auth</string>
|
||||
<string name="nests_servers_recommended_section">Recommended servers</string>
|
||||
<string name="nests_servers_recommended_explainer">Built-in suggestions you can add to your list with one tap.</string>
|
||||
<string name="nests_servers_use_defaults">Use Amethyst defaults</string>
|
||||
|
||||
+12
-8
@@ -44,7 +44,7 @@ class EditNestViewModelTest {
|
||||
buildList<Array<String>> {
|
||||
add(arrayOf("d", dTag))
|
||||
add(arrayOf("room", roomName))
|
||||
add(arrayOf("status", StatusTag.STATUS.OPEN.code))
|
||||
add(arrayOf("status", StatusTag.STATUS.LIVE.code))
|
||||
add(arrayOf("service", service))
|
||||
add(arrayOf("endpoint", endpoint))
|
||||
add(arrayOf("summary", summary))
|
||||
@@ -85,14 +85,18 @@ class EditNestViewModelTest {
|
||||
val newForm = form(dTag = "rt-42", roomName = "New name", summary = "New summary")
|
||||
|
||||
val template =
|
||||
EditNestViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.OPEN)
|
||||
EditNestViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.LIVE)
|
||||
|
||||
// Same d-tag → same address → relay treats as replacement.
|
||||
val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1)
|
||||
assertEquals("rt-42", dTag)
|
||||
|
||||
val roomTag = template.tags.firstOrNull { it.firstOrNull() == "room" }?.getOrNull(1)
|
||||
assertEquals("New name", roomTag)
|
||||
// The rebuilt template emits the canonical `title` tag (per the
|
||||
// deployed nostrnests reference); a legacy `room` tag from the
|
||||
// source event MUST be dropped, not duplicated.
|
||||
val titleTag = template.tags.firstOrNull { it.firstOrNull() == "title" }?.getOrNull(1)
|
||||
assertEquals("New name", titleTag)
|
||||
assertNull(template.tags.firstOrNull { it.firstOrNull() == "room" })
|
||||
|
||||
val summaryTag = template.tags.firstOrNull { it.firstOrNull() == "summary" }?.getOrNull(1)
|
||||
assertEquals("New summary", summaryTag)
|
||||
@@ -113,7 +117,7 @@ class EditNestViewModelTest {
|
||||
val newForm = form(dTag = "rt-1", roomName = "Renamed")
|
||||
|
||||
val template =
|
||||
EditNestViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.OPEN)
|
||||
EditNestViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.LIVE)
|
||||
|
||||
val pTagPubkeys = template.tags.filter { it.firstOrNull() == "p" }.map { it[1] }
|
||||
// Host plus two speakers — none can be lost on a rename.
|
||||
@@ -129,10 +133,10 @@ class EditNestViewModelTest {
|
||||
val sameForm = form(dTag = "rt-99", roomName = src.room().orEmpty())
|
||||
|
||||
val template =
|
||||
EditNestViewModel.buildEditTemplate(src, sameForm, StatusTag.STATUS.CLOSED)
|
||||
EditNestViewModel.buildEditTemplate(src, sameForm, StatusTag.STATUS.ENDED)
|
||||
|
||||
val statusTag = template.tags.firstOrNull { it.firstOrNull() == "status" }?.getOrNull(1)
|
||||
assertEquals(StatusTag.STATUS.CLOSED.code, statusTag)
|
||||
assertEquals(StatusTag.STATUS.ENDED.code, statusTag)
|
||||
val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1)
|
||||
assertEquals("rt-99", dTag)
|
||||
}
|
||||
@@ -143,7 +147,7 @@ class EditNestViewModelTest {
|
||||
val emptied = form(dTag = "rt-1", roomName = "X", summary = " ", imageUrl = "")
|
||||
|
||||
val template =
|
||||
EditNestViewModel.buildEditTemplate(src, emptied, StatusTag.STATUS.OPEN)
|
||||
EditNestViewModel.buildEditTemplate(src, emptied, StatusTag.STATUS.LIVE)
|
||||
|
||||
// Blank inputs MUST NOT carry over from the original; otherwise
|
||||
// a "delete the summary" edit would silently fail.
|
||||
|
||||
+55
-27
@@ -20,12 +20,12 @@ Every other EGG layers on top of this event.
|
||||
"pubkey": "<host pubkey hex>",
|
||||
"tags": [
|
||||
["d", "<room id>"],
|
||||
["room", "<room name>"],
|
||||
["title", "<room name>"],
|
||||
["summary", "<one-line description>"],
|
||||
["image", "<optional cover image URL>"],
|
||||
["status", "open" | "private" | "closed" | "planned"],
|
||||
["service", "<https URL of moq-auth sidecar>"],
|
||||
["endpoint", "<https URL of moq-relay WebTransport>"],
|
||||
["status", "live" | "private" | "ended" | "planned"],
|
||||
["streaming", "<https URL of moq-relay WebTransport>"],
|
||||
["auth", "<https URL of moq-auth sidecar>"],
|
||||
["relays", "<wss relay 1>", "<wss relay 2>", ...], // ONE tag, multiple values
|
||||
["p", "<pubkey>", "<relay hint>", "host" | "admin" | "speaker"],
|
||||
...
|
||||
@@ -43,34 +43,60 @@ and whose remaining elements are wss URLs. Implementers MUST NOT emit one
|
||||
malformed event and MAY repair it by concatenating the values, but
|
||||
publishers that emit it are non-conformant.
|
||||
|
||||
### Tag-name back-compat
|
||||
|
||||
Earlier drafts of this EGG used `room` for the room name, `service` for the
|
||||
auth URL, `endpoint` for the relay URL, and `open` / `closed` for the live /
|
||||
ended states. Those names are still emitted by some legacy clients. Receivers
|
||||
MUST accept all of:
|
||||
|
||||
| Canonical (this EGG) | Legacy (older drafts) |
|
||||
|----------------------|------------------------------|
|
||||
| `title` | `room` |
|
||||
| `streaming` | `endpoint` |
|
||||
| `auth` | `service` |
|
||||
| `status: live` | `status: open` |
|
||||
| `status: ended` | `status: closed` |
|
||||
|
||||
When a single event carries both spellings (e.g. `streaming` AND `endpoint`),
|
||||
the canonical name wins; the legacy name is treated as a duplicate. Publishers
|
||||
MUST emit only the canonical names.
|
||||
|
||||
## Behavior
|
||||
|
||||
1. Hosts MUST emit exactly one `kind:30312` event per room. Updating the room
|
||||
(rename, status change, role grants) MUST re-publish the same `d` tag with
|
||||
a higher `created_at`.
|
||||
2. The `room`, `status`, `service`, and `endpoint` tags MUST be present. A
|
||||
2. The `title`, `status`, `streaming`, and `auth` tags MUST be present. A
|
||||
client receiving an event without all four MUST treat the room as
|
||||
un-joinable.
|
||||
3. The `service` value MUST be the base URL of an EGG-02 auth sidecar (do
|
||||
3. The `auth` value MUST be the base URL of an EGG-02 auth sidecar (do
|
||||
not include the `/auth` suffix). Receivers MUST normalize the value by
|
||||
stripping a single trailing `/` before constructing sub-paths.
|
||||
4. The `endpoint` value MUST be the base URL of a WebTransport-capable
|
||||
moq-relay implementing EGG-03.
|
||||
stripping a single trailing `/` before constructing sub-paths. The auth
|
||||
sidecar speaks HTTP/1.1 or HTTP/2 over TCP/TLS — it MUST be reachable on
|
||||
a port that accepts TCP (typically 443).
|
||||
4. The `streaming` value MUST be the base URL of a WebTransport-capable
|
||||
moq-relay implementing EGG-03. Because WebTransport runs over QUIC, the
|
||||
`streaming` host's port may listen on UDP only — clients MUST NOT use
|
||||
it for the JWT-mint POST. The two URLs are separate fields precisely so
|
||||
they can live on different hosts/ports (the public nostrnests deployment
|
||||
does: `streaming = https://moq.nostrnests.com:4443`,
|
||||
`auth = https://moq-auth.nostrnests.com`).
|
||||
5. The `p` tag MUST list the host as the first participant. Additional
|
||||
`p` tags grant roles (EGG-07).
|
||||
6. Status semantics:
|
||||
- `open` — room is live, anyone can join. The auth sidecar MUST mint a
|
||||
listener token to any well-formed NIP-98 request.
|
||||
- `live` — room is in progress, anyone can join. The auth sidecar MUST
|
||||
mint a listener token to any well-formed NIP-98 request.
|
||||
- `private` — room is live, but the auth sidecar applies an out-of-band
|
||||
allowlist. The allowlist mechanism is implementation-defined; this
|
||||
spec only mandates that a non-allowlisted requester receives `403`
|
||||
(see EGG-02 error taxonomy). Clients without a path to acquire access
|
||||
MUST render `private` rooms as un-joinable rather than attempt to
|
||||
connect blind.
|
||||
- `closed` — room is over, audio plane SHOULD be torn down server-side.
|
||||
- `ended` — room is over, audio plane SHOULD be torn down server-side.
|
||||
- `planned` — room hasn't started; see EGG-08.
|
||||
7. A host MAY treat a room as auto-closed after 8 h of `created_at` staleness
|
||||
even if the published status is still `open`/`private`. Receivers SHOULD do
|
||||
7. A host MAY treat a room as auto-ended after 8 h of `created_at` staleness
|
||||
even if the published status is still `live`/`private`. Receivers SHOULD do
|
||||
the same to avoid stale rooms hanging at the top of the live UI.
|
||||
8. An empty `content` field is REQUIRED. Future EGGs MAY define structured
|
||||
content; until then, peers MUST ignore non-empty content rather than
|
||||
@@ -101,15 +127,17 @@ publishers that emit it are non-conformant.
|
||||
Auth sidecars that cannot find the room MUST return HTTP 410
|
||||
`unknown_room` (EGG-02 error taxonomy); peers SHOULD retry with
|
||||
1 s / 2 s / 4 s exponential backoff before surfacing the error.
|
||||
13. **Service / endpoint selection (host-side guidance).** When
|
||||
composing a new room, hosts SHOULD pre-fill `service` and
|
||||
`endpoint` from the FIRST entry of their own `kind:10112` user
|
||||
server list (EGG-09). When the host has no `kind:10112`, the
|
||||
client MAY ship a built-in default URL but MUST allow user
|
||||
override. Both `service` and `endpoint` MUST be `https://` URLs;
|
||||
`http://` MUST be rejected at compose time (mirrors EGG-09
|
||||
rule 1). The `service` and `endpoint` MAY be the same base URL
|
||||
(a single deployment serving both is the common case).
|
||||
13. **Streaming / auth selection (host-side guidance).** When
|
||||
composing a new room, hosts SHOULD pre-fill `streaming` and
|
||||
`auth` from the FIRST entry of their own `kind:10112` user
|
||||
server list (EGG-09 — each entry already carries the pair).
|
||||
When the host has no `kind:10112`, the client MAY ship a
|
||||
built-in default pair but MUST allow user override. Both
|
||||
`streaming` and `auth` MUST be `https://` URLs; `http://` MUST
|
||||
be rejected at compose time (mirrors EGG-09 rule 1). The two
|
||||
URLs MAY point at the same host:port for community deployments
|
||||
that genuinely co-locate both services, but they are independent
|
||||
fields and clients MUST NOT assume they share an authority.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -120,11 +148,11 @@ publishers that emit it are non-conformant.
|
||||
"created_at": 1714003200,
|
||||
"tags": [
|
||||
["d", "office-hours-2026-04"],
|
||||
["room", "Office Hours"],
|
||||
["title", "Office Hours"],
|
||||
["summary", "Weekly Q&A"],
|
||||
["status", "open"],
|
||||
["service", "https://moq.nostrnests.com"],
|
||||
["endpoint", "https://moq.nostrnests.com"],
|
||||
["status", "live"],
|
||||
["streaming", "https://moq.nostrnests.com:4443"],
|
||||
["auth", "https://moq-auth.nostrnests.com"],
|
||||
["p", "abc...host", "wss://relay.example", "host"],
|
||||
["p", "def...co", "wss://relay.example", "speaker"]
|
||||
],
|
||||
|
||||
+16
-12
@@ -11,15 +11,17 @@ auth sidecar (moq-auth) using a NIP-98 HTTP signature, receives a short-lived
|
||||
JWT, then opens a WebTransport session against the moq-relay carrying the JWT
|
||||
in the URL.
|
||||
|
||||
Two separate URLs are involved: the `service` tag from EGG-01 is the auth
|
||||
sidecar; the `endpoint` tag is the relay.
|
||||
Two separate URLs are involved: the `auth` tag from EGG-01 is the auth
|
||||
sidecar (HTTP/1.1 or HTTP/2 over TCP); the `streaming` tag is the moq-relay
|
||||
(WebTransport over QUIC). The deployed nostrnests reference puts these on
|
||||
different hosts — see EGG-01 §4 for why they cannot be collapsed.
|
||||
|
||||
## Wire format
|
||||
|
||||
### Step 1 — token request
|
||||
|
||||
```
|
||||
POST <service>/auth
|
||||
POST <auth>/auth
|
||||
Authorization: Nostr <base64(NIP-98 kind:27235 event JSON)>
|
||||
Content-Type: application/json; charset=utf-8
|
||||
|
||||
@@ -29,8 +31,10 @@ Content-Type: application/json; charset=utf-8
|
||||
|
||||
`<kind>` is `30312` for nests audio rooms (EGG-01).
|
||||
|
||||
The `<service>` URL is the EGG-01 `service` tag value with any trailing `/`
|
||||
stripped, then `/auth` appended literally.
|
||||
The `<auth>` URL is the EGG-01 `auth` tag value with any trailing `/`
|
||||
stripped, then `/auth` appended literally. (The same path component is
|
||||
used regardless of how the host is named — `<auth>` here is just the
|
||||
sidecar base URL, so the full request line is `POST <auth-base>/auth`.)
|
||||
|
||||
The body is a single-line UTF-8 JSON object — the server hashes the **exact
|
||||
bytes sent** to compare against NIP-98's `payload` tag, so producers MUST
|
||||
@@ -50,7 +54,7 @@ The signed kind 27235 event MUST carry exactly these tags (NIP-98 §1):
|
||||
"pubkey": "<requester pubkey hex>",
|
||||
"created_at": <unix seconds>,
|
||||
"tags": [
|
||||
["u", "<service>/auth"], // exact request URL, scheme included
|
||||
["u", "<auth>/auth"], // exact request URL, scheme included
|
||||
["method", "POST"],
|
||||
["payload", "<sha256 of request body, lowercase hex>"]
|
||||
],
|
||||
@@ -91,7 +95,7 @@ RFC 7518 §3.4). The auth sidecar MUST publish its public verification keys
|
||||
as a JWKS at:
|
||||
|
||||
```
|
||||
GET <service>/.well-known/jwks.json
|
||||
GET <auth>/.well-known/jwks.json
|
||||
```
|
||||
|
||||
The response is an `application/json` body shaped per RFC 7517:
|
||||
@@ -108,7 +112,7 @@ The response is an `application/json` body shaped per RFC 7517:
|
||||
}
|
||||
```
|
||||
|
||||
The relay (EGG-03 endpoint) MUST verify inbound JWTs against this JWKS.
|
||||
The relay (EGG-03 streaming endpoint) MUST verify inbound JWTs against this JWKS.
|
||||
Relays SHOULD cache the JWKS for at most 5 minutes so a key rotation
|
||||
propagates without requiring a relay restart. A relay that cannot reach
|
||||
the JWKS endpoint MUST refuse new sessions rather than fall through to
|
||||
@@ -120,7 +124,7 @@ the JWKS endpoint MUST refuse new sessions rather than fall through to
|
||||
:method = CONNECT
|
||||
:protocol = webtransport
|
||||
:scheme = https
|
||||
:authority = <host:port from `endpoint`>
|
||||
:authority = <host:port from `streaming`>
|
||||
:path = /<namespace>?jwt=<token>
|
||||
```
|
||||
|
||||
@@ -167,7 +171,7 @@ MAY ignore `reason` but MUST surface `error` to user-facing error toasts.
|
||||
| 401 | `wrong_method` | NIP-98 `method` tag is not `POST` |
|
||||
| 401 | `wrong_payload` | NIP-98 `payload` tag does not match sha256 of the body bytes |
|
||||
| 401 | `stale` | NIP-98 `created_at` outside the ±60 s tolerance |
|
||||
| 403 | `room_closed` | room status is `closed` (EGG-01) or `planned` (EGG-08) |
|
||||
| 403 | `room_closed` | room status is `ended` (EGG-01) or `planned` (EGG-08) |
|
||||
| 403 | `not_invited` | room status is `private` and requester is not on the allowlist |
|
||||
| 403 | `publish_forbidden` | `publish: true` requested but caller is not a speaker per EGG-07 |
|
||||
| 410 | `unknown_room` | no `kind:30312` known to the sidecar for `(host, d)` |
|
||||
@@ -177,7 +181,7 @@ MAY ignore `reason` but MUST surface `error` to user-facing error toasts.
|
||||
Receivers MUST treat unknown 4xx slugs as "fatal, do not retry" and
|
||||
unknown 5xx slugs as "transient, retry with exponential backoff".
|
||||
|
||||
The relay (EGG-03 endpoint) signals authorization failures through
|
||||
The relay (EGG-03 streaming endpoint) signals authorization failures through
|
||||
WebTransport CONNECT response codes, NOT through the auth-sidecar table:
|
||||
|
||||
| WT status | meaning |
|
||||
@@ -190,7 +194,7 @@ WebTransport CONNECT response codes, NOT through the auth-sidecar table:
|
||||
## Example
|
||||
|
||||
```
|
||||
> POST https://moq.nostrnests.com/auth
|
||||
> POST https://moq-auth.nostrnests.com/auth
|
||||
> Authorization: Nostr eyJ...kind27235...
|
||||
> Content-Type: application/json
|
||||
>
|
||||
|
||||
+55
-22
@@ -1,29 +1,33 @@
|
||||
# EGG-09: User server list (`kind:10112`)
|
||||
|
||||
`status: draft`
|
||||
`requires: NIP-01`
|
||||
`requires: NIP-01, EGG-01`
|
||||
`category: optional`
|
||||
|
||||
## Summary
|
||||
|
||||
A user MAY publish a list of nests servers they prefer to host on. Clients
|
||||
read this list to default-fill the "service" and "endpoint" fields when the
|
||||
user opens a new room, and to suggest peer rooms in discovery surfaces.
|
||||
read this list to default-fill the `streaming` (relay) and `auth` (sidecar)
|
||||
fields of a new `kind:30312` room (EGG-01), and to suggest peer rooms in
|
||||
discovery surfaces.
|
||||
|
||||
This is the audio-rooms equivalent of the user-server-list patterns in
|
||||
Blossom (BUD-03) and Mostr.
|
||||
Blossom (BUD-03) and Mostr — but every entry carries **two** URLs because
|
||||
the moq-relay and moq-auth sidecar live on different hosts in the
|
||||
deployed reference (see EGG-01 §4).
|
||||
|
||||
## Wire format
|
||||
|
||||
`kind:10112` is a NIP-01 *replaceable* event with one entry per server:
|
||||
`kind:10112` is a NIP-01 *replaceable* event. Each `["server", relay, auth]`
|
||||
tag carries one deployment:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10112,
|
||||
"pubkey": "<user pubkey hex>",
|
||||
"tags": [
|
||||
["server", "<https URL — moq-auth/moq-relay base>"],
|
||||
["server", "<https URL>"],
|
||||
["server", "<https URL of moq-relay>", "<https URL of moq-auth>"],
|
||||
["server", "<https URL of moq-relay>", "<https URL of moq-auth>"],
|
||||
...
|
||||
],
|
||||
"content": "",
|
||||
@@ -31,29 +35,58 @@ Blossom (BUD-03) and Mostr.
|
||||
}
|
||||
```
|
||||
|
||||
Each `["server", url]` is one entry. The relative ordering MUST be
|
||||
preserved by receivers — earlier entries are higher priority.
|
||||
The first element of the tag is the literal string `"server"`. The second
|
||||
is the moq-relay (WebTransport) URL — what the kind-30312 `streaming` tag
|
||||
will end up with. The third is the moq-auth (JWT mint) URL — what the
|
||||
kind-30312 `auth` tag will end up with.
|
||||
|
||||
The relative ordering MUST be preserved by receivers — earlier entries are
|
||||
higher priority.
|
||||
|
||||
### Back-compat shapes
|
||||
|
||||
Earlier deployed clients used two looser shapes; receivers MUST tolerate
|
||||
both, publishers MUST emit only the canonical 3-element form above:
|
||||
|
||||
1. **Legacy first-element name `relay`.** The earliest NestsUI iteration
|
||||
wrote `["relay", relay, auth]`. Receivers MUST accept this name as a
|
||||
synonym for `server`.
|
||||
2. **Auth-URL omitted.** A 2-element `["server", relay]` (or
|
||||
`["relay", relay]`) entry has no auth URL on the wire. Receivers MUST
|
||||
derive the auth URL from the relay URL using the following rule, which
|
||||
matches the deployed nostrnests fallback:
|
||||
- Replace a leading `moq.` host label with `moq-auth.` (preserving the
|
||||
scheme and dropping any explicit port).
|
||||
Example: `https://moq.example.com:4443` → `https://moq-auth.example.com`.
|
||||
- If the relay host has no `moq.` prefix, prepend `moq-auth.`.
|
||||
Example: `https://relay.example.com:4443` → `https://moq-auth.relay.example.com`.
|
||||
- If the relay URL is unparseable, drop the entry.
|
||||
|
||||
Receivers SHOULD NOT cache the legacy 2-element form — when re-publishing
|
||||
the user's list (EGG-09 rule 4), they MUST emit the 3-element form with
|
||||
the derived auth URL filled in.
|
||||
|
||||
## Behavior
|
||||
|
||||
1. Each `server` value MUST be a fully-qualified URL beginning with
|
||||
`https://`. Receivers MUST reject (drop) entries that are not
|
||||
well-formed HTTPS URLs.
|
||||
2. Receivers MUST de-duplicate entries by exact-string match after
|
||||
trimming a single trailing `/`. Order of remaining entries MUST be
|
||||
preserved (the FIRST occurrence wins).
|
||||
1. Each `relay` and each `auth` value MUST be a fully-qualified URL beginning
|
||||
with `https://`. Receivers MUST drop entries that are not well-formed
|
||||
HTTPS URLs.
|
||||
2. Receivers MUST de-duplicate entries by exact-string match on the relay
|
||||
URL (after trimming a single trailing `/`). Order of remaining entries
|
||||
MUST be preserved (the FIRST occurrence wins).
|
||||
3. When the user opens the create-room sheet, the client SHOULD pre-fill
|
||||
the EGG-01 `service` and `endpoint` fields from the FIRST entry in
|
||||
the list (a single nests deployment serves both via the same base
|
||||
URL today).
|
||||
the EGG-01 `streaming` and `auth` fields from the FIRST entry in the
|
||||
list. The `streaming` field gets the entry's relay URL; the `auth`
|
||||
field gets the entry's auth URL. Clients MUST NOT collapse the two
|
||||
into a single field.
|
||||
4. Users MAY enumerate up to 64 servers. Receivers MUST tolerate longer
|
||||
lists by truncating to the first 64.
|
||||
5. Hosts who list a server in their `kind:10112` are not declaring an
|
||||
alliance — they are merely advertising "I will probably create rooms
|
||||
here". Receivers MUST NOT use the list as a moderation signal.
|
||||
6. The list is purely a defaults / discovery hint. A `kind:30312` event's
|
||||
own `service` / `endpoint` tags are authoritative for that specific
|
||||
room and override the user list at join time.
|
||||
own `streaming` / `auth` tags are authoritative for that specific room
|
||||
and override the user list at join time.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -63,8 +96,8 @@ preserved by receivers — earlier entries are higher priority.
|
||||
"pubkey": "abc...host",
|
||||
"created_at": 1714003000,
|
||||
"tags": [
|
||||
["server", "https://moq.nostrnests.com"],
|
||||
["server", "https://moq.example.org"]
|
||||
["server", "https://moq.nostrnests.com:4443", "https://moq-auth.nostrnests.com"],
|
||||
["server", "https://relay.example.org:4443", "https://moq-auth.example.org"]
|
||||
],
|
||||
"content": "",
|
||||
"id": "...",
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
# NIP-53
|
||||
|
||||
## Live Activities
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
Service providers want to offer live activities to the Nostr network in such a way that participants can easily logon, chat, send zaps and follow other participants. This NIP describes a framework to advertise and discover hosts of live activities, such as streaming and audio rooms.
|
||||
|
||||
## Format
|
||||
|
||||
The format uses two NIP-01 *addressable* events, plus one *replaceable* event for user preferences:
|
||||
|
||||
| kind | name | scope |
|
||||
|-------|---------------------------------|----------------------|
|
||||
| 30311 | Live Event | streaming |
|
||||
| 30312 | Interactive Room (Audio Space) | nests / audio rooms |
|
||||
| 10112 | User MoQ-Server List | nests user prefs |
|
||||
|
||||
This document is a **proposed update to NIP-53** based on what the
|
||||
deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs)
|
||||
actually emits. Earlier versions of this NIP described kind 30312
|
||||
with the `service`, `endpoint`, and `status: open|closed` tags; the
|
||||
deployed reference uses `auth`, `streaming`, and `status: live|ended`
|
||||
instead. Receivers MUST tolerate both tag-name spellings (canonical
|
||||
vs. legacy) but publishers MUST emit the canonical names defined
|
||||
below.
|
||||
|
||||
### Live Event (kind 30311)
|
||||
|
||||
Unchanged from the current NIP-53. Streaming hosts publish a
|
||||
NIP-01 addressable event with the following tags:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30311,
|
||||
"tags": [
|
||||
["d", "<unique identifier>"],
|
||||
["title", "<name of the event>"],
|
||||
["summary", "<description>"],
|
||||
["image", "<preview image url>"],
|
||||
["t", "hashtag"],
|
||||
["streaming", "<url>"],
|
||||
["recording", "<url>"],
|
||||
["starts", "<unix timestamp in seconds>"],
|
||||
["ends", "<unix timestamp in seconds>"],
|
||||
["status", "<planned, live, ended>"],
|
||||
["current_participants", "<number>"],
|
||||
["total_participants", "<number>"],
|
||||
["p", "<pubkey>", "<relay url>", "<role>", "<proof>"],
|
||||
["relays", "<relay 1>", "<relay 2>", ...]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Interactive Room (kind 30312)
|
||||
|
||||
A `kind:30312` is the audio-room (a.k.a. **nest**) counterpart of a
|
||||
streaming Live Event. The event's pubkey is the host. Subscribers read
|
||||
this event to learn who runs the room, where the audio plane lives,
|
||||
who else is invited, and whether the room is currently live.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30312,
|
||||
"pubkey": "<host pubkey hex>",
|
||||
"tags": [
|
||||
["d", "<room id>"],
|
||||
["title", "<room name>"],
|
||||
["summary", "<one-line description>"],
|
||||
["image", "<optional cover image URL>"],
|
||||
["status", "live" | "private" | "ended" | "planned"],
|
||||
["streaming", "<https URL of moq-relay WebTransport>"],
|
||||
["auth", "<https URL of moq-auth sidecar>"],
|
||||
["starts", "<unix timestamp in seconds>"],
|
||||
["color", "<gradient identifier or hex>"],
|
||||
["relays", "<wss relay 1>", "<wss relay 2>", ...],
|
||||
["p", "<pubkey>", "<relay hint>", "host" | "admin" | "speaker"],
|
||||
...
|
||||
],
|
||||
"content": "",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Required tags: `d`, `title`, `status`, `streaming`, `auth`, plus a
|
||||
single `p` entry naming the host.
|
||||
|
||||
#### `streaming` tag
|
||||
|
||||
Base URL of a WebTransport-capable moq-relay (the audio plane). The
|
||||
relay listens on QUIC; the URL's port may accept UDP only. Clients
|
||||
MUST NOT issue HTTP requests against this URL — it is the
|
||||
WebTransport authority for the `:authority` pseudo-header during
|
||||
session establishment.
|
||||
|
||||
#### `auth` tag
|
||||
|
||||
Base URL of the moq-auth sidecar (the JWT mint). The sidecar speaks
|
||||
HTTP/1.1 or HTTP/2 over TCP/TLS — it MUST be reachable on a port
|
||||
that accepts TCP (typically 443). Clients MUST NOT collapse this URL
|
||||
into the `streaming` URL: the public reference deployment hosts them
|
||||
on different hosts (`moq.nostrnests.com:4443` for the relay,
|
||||
`moq-auth.nostrnests.com` for auth), and an HTTP request against the
|
||||
QUIC-only relay port hangs without ever reaching a server.
|
||||
|
||||
The two URLs MAY point at the same authority for community
|
||||
deployments that genuinely co-locate both services, but they remain
|
||||
independent fields and clients MUST NOT assume they share an
|
||||
authority.
|
||||
|
||||
#### `status` tag
|
||||
|
||||
| value | meaning |
|
||||
|-----------|--------------------------------------------------------------------|
|
||||
| `live` | Room in progress, anyone may join. Auth sidecar mints listener tokens. |
|
||||
| `private` | Room in progress with an out-of-band allowlist. Auth sidecar replies `403` to non-allowlisted requesters. |
|
||||
| `ended` | Room is over; audio plane SHOULD be torn down server-side. |
|
||||
| `planned` | Room is scheduled to start at the `starts` unix-second timestamp. |
|
||||
|
||||
Receivers MUST also accept `open` (synonym for `live`) and `closed`
|
||||
(synonym for `ended`) on read for back-compat with earlier drafts.
|
||||
Publishers MUST emit only the canonical names.
|
||||
|
||||
#### `p` tag (participant)
|
||||
|
||||
`["p", <pubkey hex>, <relay hint>, <role>, <proof>]`
|
||||
|
||||
`role` is one of `host`, `admin`, `speaker`. Role flips re-publish
|
||||
the kind-30312 event. `proof` is reserved for invitee
|
||||
acknowledgements; absent today.
|
||||
|
||||
#### `relays` tag
|
||||
|
||||
A SINGLE tag whose first element is the literal string `"relays"`
|
||||
and whose remaining elements are wss URLs. Implementers MUST NOT
|
||||
emit one `["relays", url]` tag per relay.
|
||||
|
||||
#### Authentication & WebTransport handshake
|
||||
|
||||
To open the audio plane, a peer mints a JWT against the auth sidecar
|
||||
using NIP-98 and then opens a WebTransport CONNECT against the relay
|
||||
carrying the JWT in the URL.
|
||||
|
||||
##### Step 1 — token request
|
||||
|
||||
```
|
||||
POST <auth>/auth
|
||||
Authorization: Nostr <base64(NIP-98 kind:27235 event JSON)>
|
||||
Content-Type: application/json; charset=utf-8
|
||||
|
||||
{ "namespace": "nests/30312:<host_pubkey_hex>:<room_d>",
|
||||
"publish": <boolean> }
|
||||
```
|
||||
|
||||
`<auth>` is the kind-30312 `auth` tag value with any trailing `/`
|
||||
stripped. The body is a single-line UTF-8 JSON object — the server
|
||||
hashes the **exact bytes sent** to compare against NIP-98's `payload`
|
||||
tag, so producers MUST NOT pretty-print or re-order keys after
|
||||
signing. The NIP-98 event's `u` tag MUST be the full URL
|
||||
`<auth>/auth` (scheme included).
|
||||
|
||||
`publish: true` requests a speaker token; `publish: false` a listener
|
||||
token. The auth sidecar MUST gate `publish: true` on the requester
|
||||
appearing in the kind-30312 event's `p` tag with role `host`,
|
||||
`admin`, or `speaker`.
|
||||
|
||||
##### Step 2 — token response
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{ "token": "<jwt>" }
|
||||
```
|
||||
|
||||
The JWT is signed `alg: ES256`. The sidecar MUST publish its public
|
||||
keys at `GET <auth>/.well-known/jwks.json` (RFC 7517). The relay
|
||||
MUST verify inbound JWTs against this JWKS and SHOULD cache it for
|
||||
at most 5 minutes.
|
||||
|
||||
JWT claims (minimum):
|
||||
|
||||
| claim | meaning |
|
||||
|---------|--------------------------------------------------------------------|
|
||||
| `root` | The `namespace` echoed back. Authorization is scoped here. |
|
||||
| `get` | Read-allowed sub-paths; for nests this is `[""]` (any). |
|
||||
| `put` | Publish-allowed sub-paths. Listener: `[]`. Speaker: `[<pubkey>]`. |
|
||||
| `iat` | Unix seconds; issuance. |
|
||||
| `exp` | Unix seconds; expiry. Recommended `iat + 600`. |
|
||||
|
||||
There is no refresh endpoint. Long sessions MUST mint a fresh token
|
||||
and open a new WebTransport session before the old token expires.
|
||||
|
||||
##### Step 3 — WebTransport CONNECT
|
||||
|
||||
```
|
||||
:method = CONNECT
|
||||
:protocol = webtransport
|
||||
:scheme = https
|
||||
:authority = <host:port from `streaming`>
|
||||
:path = /<namespace>?jwt=<token>
|
||||
```
|
||||
|
||||
The relay reads the JWT from the `?jwt=` query parameter and MUST
|
||||
NOT trust the WebTransport authority for authorization — only the
|
||||
JWT's `root` claim. A token issued for namespace A MUST NOT be
|
||||
accepted on a session opened against namespace B.
|
||||
|
||||
Peers MUST NOT log the JWT or include it in error reports.
|
||||
|
||||
##### Error taxonomy
|
||||
|
||||
The auth sidecar MUST return `application/json` bodies of shape
|
||||
`{ "error": "<slug>", "reason": "<human string>" }` on non-2xx
|
||||
responses. Defined slugs:
|
||||
|
||||
| status | slug | when |
|
||||
|--------|---------------------|------------------------------------------------------------------|
|
||||
| 400 | `bad_request` | malformed JSON body or missing `namespace` |
|
||||
| 400 | `bad_namespace` | namespace does not match `nests/<kind>:<hexpubkey>:<d>` |
|
||||
| 401 | `bad_nip98` | Authorization header missing, malformed, or `id`/`sig` invalid |
|
||||
| 401 | `wrong_url` | NIP-98 `u` tag does not match the actual request URL |
|
||||
| 401 | `wrong_method` | NIP-98 `method` tag is not `POST` |
|
||||
| 401 | `wrong_payload` | NIP-98 `payload` tag does not match sha256 of the body bytes |
|
||||
| 401 | `stale` | NIP-98 `created_at` outside the ±60 s tolerance |
|
||||
| 403 | `room_closed` | room status is `ended` or `planned` |
|
||||
| 403 | `not_invited` | room status is `private` and requester not on allowlist |
|
||||
| 403 | `publish_forbidden` | `publish: true` requested but caller is not a speaker |
|
||||
| 410 | `unknown_room` | no `kind:30312` known to the sidecar for `(host, d)` |
|
||||
| 429 | `rate_limited` | per-pubkey or per-IP rate limit; `Retry-After` SHOULD be set |
|
||||
| 5xx | `internal` | sidecar internal error |
|
||||
|
||||
The relay signals authorization failures through WebTransport
|
||||
CONNECT response codes:
|
||||
|
||||
| WT status | meaning |
|
||||
|-----------|------------------------------------------------------------------------|
|
||||
| 200 | session established |
|
||||
| 401 | JWT signature invalid, expired, or fails the JWKS check |
|
||||
| 403 | JWT `root` does not match path namespace, or `put` claim missing |
|
||||
| 404 | path namespace unknown to the relay |
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30312,
|
||||
"pubkey": "abc...host",
|
||||
"created_at": 1714003200,
|
||||
"tags": [
|
||||
["d", "office-hours-2026-04"],
|
||||
["title", "Office Hours"],
|
||||
["summary", "Weekly Q&A"],
|
||||
["status", "live"],
|
||||
["streaming", "https://moq.nostrnests.com:4443"],
|
||||
["auth", "https://moq-auth.nostrnests.com"],
|
||||
["p", "abc...host", "wss://relay.example", "host"],
|
||||
["p", "def...co", "wss://relay.example", "speaker"]
|
||||
],
|
||||
"content": "",
|
||||
"id": "...",
|
||||
"sig": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### User MoQ-Server List (kind 10112)
|
||||
|
||||
A user MAY publish a list of nests servers they prefer to host on as
|
||||
a NIP-01 *replaceable* event. Clients read this list to default-fill
|
||||
the `streaming` and `auth` tags of a new `kind:30312` room.
|
||||
|
||||
Each entry is a single tag carrying **two** URLs because the
|
||||
moq-relay and moq-auth sidecar live on different hosts in the
|
||||
deployed reference:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10112,
|
||||
"pubkey": "<user pubkey hex>",
|
||||
"tags": [
|
||||
["server", "<https URL of moq-relay>", "<https URL of moq-auth>"],
|
||||
["server", "<https URL of moq-relay>", "<https URL of moq-auth>"],
|
||||
...
|
||||
],
|
||||
"content": ""
|
||||
}
|
||||
```
|
||||
|
||||
Tag elements are positional: index 1 is the moq-relay URL (becomes
|
||||
the kind-30312 `streaming` tag value), index 2 is the moq-auth URL
|
||||
(becomes the kind-30312 `auth` tag value). Order of tags is
|
||||
preserved by receivers; earlier entries are higher priority.
|
||||
|
||||
#### Back-compat
|
||||
|
||||
Receivers MUST accept two looser shapes from earlier deployed
|
||||
clients; publishers MUST emit only the canonical 3-element form:
|
||||
|
||||
1. **First-element name `relay`.** Earliest NestsUI iterations
|
||||
wrote `["relay", relay, auth]`. Treat as a synonym for `server`.
|
||||
2. **Auth URL omitted.** A 2-element `["server", relay]` (or
|
||||
`["relay", relay]`) carries no auth URL on the wire. Receivers
|
||||
MUST derive it from the relay URL by replacing a leading `moq.`
|
||||
host label with `moq-auth.`, or prepending `moq-auth.` when the
|
||||
relay host has no `moq.` prefix. Drop the entry if the relay URL
|
||||
is unparseable.
|
||||
|
||||
#### Behavior
|
||||
|
||||
1. Each `relay` and `auth` value MUST be a fully-qualified URL
|
||||
beginning with `https://`. Receivers MUST drop entries that are
|
||||
not well-formed HTTPS URLs.
|
||||
2. Receivers MUST de-duplicate by exact-string match on the relay
|
||||
URL after trimming a single trailing `/`. Order is preserved
|
||||
(FIRST occurrence wins).
|
||||
3. When the user opens a create-room sheet, the client SHOULD
|
||||
pre-fill the kind-30312 `streaming` and `auth` tags from the
|
||||
FIRST entry in the list. Clients MUST NOT collapse the two URLs
|
||||
into a single field.
|
||||
4. Users MAY enumerate up to 64 servers; receivers MUST tolerate
|
||||
longer lists by truncating to the first 64.
|
||||
5. The list is purely a defaults / discovery hint. A `kind:30312`
|
||||
event's own `streaming` / `auth` tags are authoritative for that
|
||||
specific room and override the user list at join time.
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 10112,
|
||||
"pubkey": "abc...host",
|
||||
"created_at": 1714003000,
|
||||
"tags": [
|
||||
["server", "https://moq.nostrnests.com:4443", "https://moq-auth.nostrnests.com"],
|
||||
["server", "https://relay.example.org:4443", "https://moq-auth.example.org"]
|
||||
],
|
||||
"content": "",
|
||||
"id": "...",
|
||||
"sig": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
Common use cases include meeting room/conference calls, watch-together activities, audio spaces, hangouts, and game streams.
|
||||
|
||||
## Notes
|
||||
|
||||
Live Activity management events are not designed to be used by relays for filtering, so clients SHOULD use the addressable event (`<kind>:<pubkey>:<d>`) when referencing room messages.
|
||||
+3
-3
@@ -65,7 +65,7 @@ class MeetingSpaceEvent(
|
||||
|
||||
fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum))
|
||||
|
||||
fun isLive() = status() != StatusTag.STATUS.CLOSED
|
||||
fun isLive() = status() != StatusTag.STATUS.ENDED
|
||||
|
||||
fun service() = tags.firstNotNullOfOrNull(ServiceUrlTag::parse)
|
||||
|
||||
@@ -119,8 +119,8 @@ class MeetingSpaceEvent(
|
||||
fun participants() = tags.mapNotNull(ParticipantTag::parse)
|
||||
|
||||
fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
|
||||
if (eventStatus != StatusTag.STATUS.CLOSED && createdAt < TimeUtils.eightHoursAgo()) {
|
||||
StatusTag.STATUS.CLOSED
|
||||
if (eventStatus != StatusTag.STATUS.ENDED && createdAt < TimeUtils.eightHoursAgo()) {
|
||||
StatusTag.STATUS.ENDED
|
||||
} else {
|
||||
eventStatus
|
||||
}
|
||||
|
||||
+11
-6
@@ -23,17 +23,22 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* URL of the moq-relay WebTransport endpoint for a NIP-53 / nests
|
||||
* audio room. Matches the deployed nostrnests reference, which writes
|
||||
* `["streaming", "<https URL>"]` on `kind:30312`. The early EGG-01
|
||||
* draft called this `endpoint`; we accept that name on read for older
|
||||
* events but always emit `streaming`.
|
||||
*/
|
||||
class EndpointUrlTag {
|
||||
companion object Companion {
|
||||
const val TAG_NAME = "endpoint"
|
||||
const val TAG_NAME = "streaming"
|
||||
|
||||
/**
|
||||
* Legacy alias used by first-generation nostrnests web clients,
|
||||
* which reused the NIP-53 streaming-event tag name for the
|
||||
* kind-30312 MoQ relay URL. Accepted on read; we always emit
|
||||
* the canonical [TAG_NAME].
|
||||
* Earlier EGG-01 draft name for the MoQ relay URL. Accepted on
|
||||
* read for back-compat; we always emit the canonical [TAG_NAME].
|
||||
*/
|
||||
const val LEGACY_TAG_NAME = "streaming"
|
||||
const val LEGACY_TAG_NAME = "endpoint"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
|
||||
+12
-5
@@ -23,17 +23,24 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Room display name on a NIP-53 / nests `kind:30312` event. Matches
|
||||
* the deployed nostrnests reference, which writes `["title", name]`
|
||||
* AND filters its lobby on the `title` tag's presence — kind-30312
|
||||
* events without a `title` are dropped from "Live Now". The early
|
||||
* EGG-01 draft called this `room`; we accept that name on read for
|
||||
* older events but always emit `title`.
|
||||
*/
|
||||
class RoomNameTag {
|
||||
companion object Companion {
|
||||
const val TAG_NAME = "room"
|
||||
const val TAG_NAME = "title"
|
||||
|
||||
/**
|
||||
* Legacy alias used by first-generation nostrnests web clients,
|
||||
* which reused the NIP-53 streaming-event `title` tag for the
|
||||
* kind-30312 room name. Accepted on read; we always emit the
|
||||
* Earlier EGG-01 draft name for the room display name.
|
||||
* Accepted on read for back-compat; we always emit the
|
||||
* canonical [TAG_NAME].
|
||||
*/
|
||||
const val LEGACY_TAG_NAME = "title"
|
||||
const val LEGACY_TAG_NAME = "room"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
|
||||
+11
-5
@@ -23,16 +23,22 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* URL of the moq-auth sidecar (the JWT mint) for a NIP-53 / nests
|
||||
* audio room. Matches the deployed nostrnests reference, which writes
|
||||
* `["auth", "<https URL>"]` on `kind:30312`. The early EGG-01 draft
|
||||
* called this `service`; we accept that name on read for older events
|
||||
* but always emit `auth`.
|
||||
*/
|
||||
class ServiceUrlTag {
|
||||
companion object Companion {
|
||||
const val TAG_NAME = "service"
|
||||
const val TAG_NAME = "auth"
|
||||
|
||||
/**
|
||||
* Legacy alias used by first-generation nostrnests web clients
|
||||
* for the moq-auth sidecar URL. Accepted on read; we always emit
|
||||
* the canonical [TAG_NAME].
|
||||
* Earlier EGG-01 draft name for the moq-auth URL. Accepted on
|
||||
* read for back-compat; we always emit the canonical [TAG_NAME].
|
||||
*/
|
||||
const val LEGACY_TAG_NAME = "auth"
|
||||
const val LEGACY_TAG_NAME = "service"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
|
||||
+27
-11
@@ -24,14 +24,32 @@ import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class StatusTag {
|
||||
/**
|
||||
* Canonical status codes match the deployed nostrnests reference:
|
||||
* `live` for an in-progress room, `ended` for a closed one,
|
||||
* `planned` for a scheduled future room. The earlier EGG-01 draft
|
||||
* used `open` / `closed`; those values are accepted on read for
|
||||
* back-compat but never emitted.
|
||||
*
|
||||
* `private` is a forward-looking value for invite-only rooms; it
|
||||
* is not yet emitted by the deployed reference but is reserved on
|
||||
* the spec side so the auth sidecar's `not_invited` error has a
|
||||
* room status to anchor against.
|
||||
*/
|
||||
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"),
|
||||
|
||||
/** Live, in-progress room (formerly `open` in the EGG-01 draft). */
|
||||
LIVE("live"),
|
||||
|
||||
/** Reserved for invite-only rooms; not yet emitted by deployed clients. */
|
||||
PRIVATE("private"),
|
||||
CLOSED("closed"),
|
||||
|
||||
/** Room is over (formerly `closed` in the EGG-01 draft). */
|
||||
ENDED("ended"),
|
||||
;
|
||||
|
||||
fun toTagArray() = assemble(this)
|
||||
@@ -41,20 +59,18 @@ class StatusTag {
|
||||
when (code) {
|
||||
PLANNED.code -> PLANNED
|
||||
|
||||
OPEN.code -> OPEN
|
||||
LIVE.code -> LIVE
|
||||
|
||||
PRIVATE.code -> PRIVATE
|
||||
|
||||
CLOSED.code -> CLOSED
|
||||
ENDED.code -> ENDED
|
||||
|
||||
// Legacy aliases used by first-generation nostrnests
|
||||
// web clients that reused NIP-53 streaming-event
|
||||
// status values for kind-30312 meeting spaces.
|
||||
// Accept on read to preserve interop; we always emit
|
||||
// the canonical EGG-01 codes.
|
||||
"live" -> OPEN
|
||||
// Earlier EGG-01 draft used "open" / "closed". Accept
|
||||
// on read for back-compat; we always emit the deployed
|
||||
// nostrnests codes ("live" / "ended").
|
||||
"open" -> LIVE
|
||||
|
||||
"ended" -> CLOSED
|
||||
"closed" -> ENDED
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
+84
-28
@@ -29,36 +29,53 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* One audio-room server entry as published in `kind:10112`. Each entry
|
||||
* names a deployment by its **two** distinct URLs: the moq-relay
|
||||
* WebTransport endpoint ([relay], goes into the kind-30312 `streaming`
|
||||
* tag) and the moq-auth sidecar base URL ([auth], goes into the
|
||||
* kind-30312 `auth` tag). The deployed nostrnests reference puts these
|
||||
* on different hosts (`moq.nostrnests.com:4443` for the relay,
|
||||
* `moq-auth.nostrnests.com` for auth) so the pair MUST be carried
|
||||
* together — collapsing them to a single URL breaks the JWT-mint flow
|
||||
* (the auth host doesn't accept TCP on the relay port and vice-versa).
|
||||
*/
|
||||
@Immutable
|
||||
data class NestsServer(
|
||||
val relay: String,
|
||||
val auth: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Replaceable event listing the audio-room (NIP-53 / nests) MoQ host
|
||||
* servers a user prefers to publish their kind-30312 spaces against.
|
||||
*
|
||||
* Kind: **10112** — declared by nostrnests's reference README under
|
||||
* "User-published audio server lists". Wire shape mirrors
|
||||
* BlossomServersEvent's 10063 layout (one `server` tag per host base
|
||||
* URL).
|
||||
* BlossomServersEvent's 10063 layout, but every `server` entry carries
|
||||
* **three** elements (tag name, relay URL, auth URL):
|
||||
*
|
||||
* {
|
||||
* "kind": 10112,
|
||||
* "tags": [
|
||||
* ["alt", "Audio-room (nests) MoQ servers used by the author"],
|
||||
* ["server", "https://moq.nostrnests.com"],
|
||||
* ["server", "https://moq.example.org"],
|
||||
* ["server", "https://moq.nostrnests.com:4443", "https://moq-auth.nostrnests.com"],
|
||||
* ["server", "https://relay.example.org:4443", "https://moq-auth.example.org"],
|
||||
* ...
|
||||
* ],
|
||||
* "content": ""
|
||||
* }
|
||||
*
|
||||
* The `server` URL points at the moq-rs / moq-auth deployment's base
|
||||
* URL — Amethyst's `CreateNestSheet` uses it for both the
|
||||
* `service` (auth sidecar) and `endpoint` (WebTransport relay) tags
|
||||
* on the kind-30312 event, since nostrnests's reference deployment
|
||||
* co-locates them. A future revision can split the two into separate
|
||||
* tag fields if the community pulls them apart.
|
||||
* On the read side we tolerate two legacy shapes the deployed
|
||||
* nostrnests reference also accepts:
|
||||
* - First-element name `relay` instead of `server` (earliest
|
||||
* iteration of the React app).
|
||||
* - 2-element form `["server", relay]` with the auth URL omitted.
|
||||
* Receivers MUST derive the auth host: replace a leading `moq.`
|
||||
* with `moq-auth.`, or prepend `moq-auth.` when no `moq.` prefix
|
||||
* is present.
|
||||
*
|
||||
* This event is shaped 1:1 after `BlossomServersEvent` (kind 10063,
|
||||
* NIP-B7) so existing list-state / settings UI patterns translate
|
||||
* directly.
|
||||
* Authoritative reference: NestsUI-v2 `useMoqServerList` hook.
|
||||
*/
|
||||
@Immutable
|
||||
class NestsServersEvent(
|
||||
@@ -69,13 +86,18 @@ class NestsServersEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun servers(): List<String> =
|
||||
tags.mapNotNull {
|
||||
if (it.size > 1 && it[0] == "server") {
|
||||
it[1]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
/**
|
||||
* Decode every well-formed `server` (or legacy `relay`) tag into a
|
||||
* [NestsServer] pair. A 2-element form gets its auth URL derived
|
||||
* via [deriveAuthUrl]; a fully-malformed tag is dropped.
|
||||
*/
|
||||
fun servers(): List<NestsServer> =
|
||||
tags.mapNotNull { tag ->
|
||||
if (tag.size < 2) return@mapNotNull null
|
||||
if (tag[0] != "server" && tag[0] != "relay") return@mapNotNull null
|
||||
val relay = tag[1].takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val auth = tag.getOrNull(2)?.takeIf { it.isNotBlank() } ?: deriveAuthUrl(relay)
|
||||
NestsServer(relay = relay, auth = auth ?: return@mapNotNull null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -88,37 +110,71 @@ class NestsServersEvent(
|
||||
|
||||
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
|
||||
|
||||
fun createTagArray(servers: List<String>): Array<Array<String>> =
|
||||
fun createTagArray(servers: List<NestsServer>): Array<Array<String>> =
|
||||
servers
|
||||
.map { arrayOf("server", it) }
|
||||
.map { arrayOf("server", it.relay, it.auth) }
|
||||
.plusElement(AltTag.assemble(ALT))
|
||||
.toTypedArray()
|
||||
|
||||
suspend fun updateRelayList(
|
||||
earlierVersion: NestsServersEvent,
|
||||
servers: List<String>,
|
||||
servers: List<NestsServer>,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): NestsServersEvent {
|
||||
val tags =
|
||||
earlierVersion.tags
|
||||
.filter { it[0] != "server" }
|
||||
.plus(servers.map { arrayOf("server", it) })
|
||||
.filter { it[0] != "server" && it[0] != "relay" }
|
||||
.plus(servers.map { arrayOf("server", it.relay, it.auth) })
|
||||
.toTypedArray()
|
||||
|
||||
return signer.sign(createdAt, KIND, tags, earlierVersion.content)
|
||||
}
|
||||
|
||||
suspend fun createFromScratch(
|
||||
relays: List<String>,
|
||||
servers: List<NestsServer>,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
) = create(relays, signer, createdAt)
|
||||
) = create(servers, signer, createdAt)
|
||||
|
||||
suspend fun create(
|
||||
servers: List<String>,
|
||||
servers: List<NestsServer>,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
) = signer.sign<NestsServersEvent>(createdAt, KIND, createTagArray(servers), "")
|
||||
|
||||
/**
|
||||
* Derive the moq-auth base URL from a moq-relay URL when a
|
||||
* legacy 2-element `server` tag omits the auth URL. Mirrors
|
||||
* the deployed nostrnests fallback in
|
||||
* `NestsUI-v2/useMoqServerList`:
|
||||
*
|
||||
* - Replace a leading `moq.` host label with `moq-auth.`
|
||||
* - Otherwise prepend `moq-auth.` to the existing host
|
||||
* - Strip the relay's explicit port (the auth sidecar always
|
||||
* lives on regular HTTPS / port 443)
|
||||
*
|
||||
* Returns null when the input isn't a parseable
|
||||
* `<scheme>://<host>[:port][/...]` URL — caller drops the entry.
|
||||
*/
|
||||
fun deriveAuthUrl(relayUrl: String): String? {
|
||||
val schemeEnd = relayUrl.indexOf("://")
|
||||
if (schemeEnd <= 0) return null
|
||||
val scheme = relayUrl.substring(0, schemeEnd)
|
||||
val rest = relayUrl.substring(schemeEnd + 3)
|
||||
val pathSlash = rest.indexOf('/')
|
||||
val authority = if (pathSlash < 0) rest else rest.substring(0, pathSlash)
|
||||
if (authority.isEmpty()) return null
|
||||
val portColon = authority.indexOf(':')
|
||||
val host = if (portColon < 0) authority else authority.substring(0, portColon)
|
||||
if (host.isEmpty()) return null
|
||||
val derivedHost =
|
||||
if (host.startsWith("moq.")) {
|
||||
"moq-auth." + host.substring("moq.".length)
|
||||
} else {
|
||||
"moq-auth.$host"
|
||||
}
|
||||
return "$scheme://$derivedHost"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -37,7 +37,7 @@ class MeetingSpaceEventBuildTest {
|
||||
val template =
|
||||
MeetingSpaceEvent.build(
|
||||
room = "Main Hall",
|
||||
status = StatusTag.STATUS.OPEN,
|
||||
status = StatusTag.STATUS.LIVE,
|
||||
service = "https://meet.example.com/hall",
|
||||
host = ParticipantTag(host, null, "Host", null),
|
||||
dTag = "main-hall",
|
||||
@@ -52,10 +52,10 @@ class MeetingSpaceEventBuildTest {
|
||||
val byName = template.tags.groupBy { it[0] }
|
||||
|
||||
assertEquals("main-hall", byName["d"]?.single()?.get(1))
|
||||
assertEquals("Main Hall", byName["room"]?.single()?.get(1))
|
||||
assertEquals("open", byName["status"]?.single()?.get(1))
|
||||
assertEquals("https://meet.example.com/hall", byName["service"]?.single()?.get(1))
|
||||
assertEquals("https://api.example.com/hall", byName["endpoint"]?.single()?.get(1))
|
||||
assertEquals("Main Hall", byName["title"]?.single()?.get(1))
|
||||
assertEquals("live", byName["status"]?.single()?.get(1))
|
||||
assertEquals("https://meet.example.com/hall", byName["auth"]?.single()?.get(1))
|
||||
assertEquals("https://api.example.com/hall", byName["streaming"]?.single()?.get(1))
|
||||
assertEquals("The big room", byName["summary"]?.single()?.get(1))
|
||||
assertEquals("https://example.com/hall.png", byName["image"]?.single()?.get(1))
|
||||
assertEquals(MeetingSpaceEvent.ALT, byName["alt"]?.single()?.get(1))
|
||||
|
||||
@@ -37,6 +37,20 @@ package com.vitorpamplona.quic.stream
|
||||
* items in `quic/plans/2026-04-26-quic-stack-status.md` — adding
|
||||
* retain-until-ACK + retransmit is the first thing to add for general
|
||||
* STREAM-heavy use.
|
||||
*
|
||||
* **Concurrency:** [enqueue] / [finish] are invoked by application
|
||||
* coroutines (e.g. WebTransport stream writers in [com.vitorpamplona.quic.webtransport.WtPeerStreamDemux]);
|
||||
* [takeChunk] runs on the [com.vitorpamplona.quic.connection.QuicConnectionDriver] send loop under the
|
||||
* connection mutex. The two paths are NOT serialised by a shared lock,
|
||||
* so the buffer's internal state (`chunks`, `pendingBytes`, `headOffset`,
|
||||
* FIN flags, offsets) is mutated under `synchronized(this)`. The cheap
|
||||
* `readableBytes` / `sentOffset` / `finPending` / `finSent` getters used
|
||||
* by the writer's pre-flight checks are also synchronised so they can't
|
||||
* read torn state. Without this, an `enqueue` racing a `takeChunk`
|
||||
* surfaced as `NoSuchElementException: ArrayDeque is empty` from
|
||||
* `chunks.first()` (the writer saw `pendingBytes > 0` after the
|
||||
* `addLast` but before the matching deque mutation became visible, so
|
||||
* it entered the head-peel branch and tripped on an empty deque).
|
||||
*/
|
||||
class SendBuffer {
|
||||
/**
|
||||
@@ -49,68 +63,72 @@ class SendBuffer {
|
||||
private var headOffset: Int = 0
|
||||
private var pendingBytes: Int = 0
|
||||
private var sentEnd: Long = 0L
|
||||
var nextOffset: Long = 0L
|
||||
private set
|
||||
var finPending: Boolean = false
|
||||
private set
|
||||
var finSent: Boolean = false
|
||||
private set
|
||||
private var _nextOffset: Long = 0L
|
||||
private var _finPending: Boolean = false
|
||||
private var _finSent: Boolean = false
|
||||
|
||||
val readableBytes: Int get() = pendingBytes
|
||||
val nextOffset: Long get() = synchronized(this) { _nextOffset }
|
||||
val finPending: Boolean get() = synchronized(this) { _finPending }
|
||||
val finSent: Boolean get() = synchronized(this) { _finSent }
|
||||
|
||||
val readableBytes: Int get() = synchronized(this) { pendingBytes }
|
||||
|
||||
/** Bytes already handed out via [takeChunk]; equal to the next offset to assign. */
|
||||
val sentOffset: Long get() = sentEnd
|
||||
val sentOffset: Long get() = synchronized(this) { sentEnd }
|
||||
|
||||
fun enqueue(bytes: ByteArray) {
|
||||
if (bytes.isEmpty()) return
|
||||
chunks.addLast(bytes)
|
||||
pendingBytes += bytes.size
|
||||
nextOffset += bytes.size
|
||||
synchronized(this) {
|
||||
chunks.addLast(bytes)
|
||||
pendingBytes += bytes.size
|
||||
_nextOffset += bytes.size
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark the write side as closing; the next [takeChunk] will set FIN once empty. */
|
||||
fun finish() {
|
||||
finPending = true
|
||||
synchronized(this) { _finPending = true }
|
||||
}
|
||||
|
||||
/** Take up to [maxBytes] bytes off the head of the buffer at the current send offset. */
|
||||
fun takeChunk(maxBytes: Int): Chunk? {
|
||||
if (pendingBytes == 0 && !(finPending && !finSent)) return null
|
||||
val cap = maxBytes.coerceAtLeast(0)
|
||||
if (cap == 0 && pendingBytes > 0) return null
|
||||
val data: ByteArray
|
||||
if (pendingBytes == 0) {
|
||||
data = ByteArray(0)
|
||||
} else {
|
||||
val head = chunks.first()
|
||||
val available = head.size - headOffset
|
||||
if (available <= cap) {
|
||||
// Hand out the rest of the head chunk. Always copy: the caller's
|
||||
// ByteArray (passed to enqueue) MUST stay opaque to the rest of
|
||||
// the stack, since downstream encoders eventually pass it to
|
||||
// AEAD.seal which assumes immutability for the duration of the
|
||||
// encryption call.
|
||||
data =
|
||||
if (headOffset == 0 && head.size == available) {
|
||||
head.copyOf()
|
||||
} else {
|
||||
head.copyOfRange(headOffset, head.size)
|
||||
}
|
||||
chunks.removeFirst()
|
||||
headOffset = 0
|
||||
pendingBytes -= available
|
||||
fun takeChunk(maxBytes: Int): Chunk? =
|
||||
synchronized(this) {
|
||||
if (pendingBytes == 0 && !(_finPending && !_finSent)) return@synchronized null
|
||||
val cap = maxBytes.coerceAtLeast(0)
|
||||
if (cap == 0 && pendingBytes > 0) return@synchronized null
|
||||
val data: ByteArray
|
||||
if (pendingBytes == 0) {
|
||||
data = ByteArray(0)
|
||||
} else {
|
||||
data = head.copyOfRange(headOffset, headOffset + cap)
|
||||
headOffset += cap
|
||||
pendingBytes -= cap
|
||||
val head = chunks.first()
|
||||
val available = head.size - headOffset
|
||||
if (available <= cap) {
|
||||
// Hand out the rest of the head chunk. Always copy: the caller's
|
||||
// ByteArray (passed to enqueue) MUST stay opaque to the rest of
|
||||
// the stack, since downstream encoders eventually pass it to
|
||||
// AEAD.seal which assumes immutability for the duration of the
|
||||
// encryption call.
|
||||
data =
|
||||
if (headOffset == 0 && head.size == available) {
|
||||
head.copyOf()
|
||||
} else {
|
||||
head.copyOfRange(headOffset, head.size)
|
||||
}
|
||||
chunks.removeFirst()
|
||||
headOffset = 0
|
||||
pendingBytes -= available
|
||||
} else {
|
||||
data = head.copyOfRange(headOffset, headOffset + cap)
|
||||
headOffset += cap
|
||||
pendingBytes -= cap
|
||||
}
|
||||
}
|
||||
val offset = sentEnd
|
||||
sentEnd += data.size
|
||||
val fin = _finPending && pendingBytes == 0
|
||||
if (fin) _finSent = true
|
||||
Chunk(offset, data, fin)
|
||||
}
|
||||
val offset = sentEnd
|
||||
sentEnd += data.size
|
||||
val fin = finPending && pendingBytes == 0
|
||||
if (fin) finSent = true
|
||||
return Chunk(offset, data, fin)
|
||||
}
|
||||
|
||||
data class Chunk(
|
||||
val offset: Long,
|
||||
|
||||
Reference in New Issue
Block a user