mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge remote-tracking branch 'upstream/main' into feat/concord-direct-invite-inline
This commit is contained in:
+257
@@ -0,0 +1,257 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.images
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Gainmap
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import coil3.BitmapImage
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.annotation.ExperimentalCoilApi
|
||||||
|
import coil3.decode.DataSource
|
||||||
|
import coil3.disk.DiskCache
|
||||||
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||||
|
import coil3.request.ErrorResult
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.SuccessResult
|
||||||
|
import coil3.request.allowHardware
|
||||||
|
import coil3.size.Precision
|
||||||
|
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
|
||||||
|
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
|
||||||
|
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||||
|
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okio.Path.Companion.toOkioPath
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks an Ultra HDR JPEG (a JPEG with an ISO 21496-1 / Android gain map) through every stage an
|
||||||
|
* uploaded or viewed photo passes, asserting the gain map survives each one. Losing it anywhere
|
||||||
|
* turns the photo into plain SDR, which is what "HDR photos don't work" looks like to a user.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class HdrGainmapPipelineInstrumentedTest {
|
||||||
|
private lateinit var hdrJpeg: File
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
assumeTrue("Gain maps need API 34+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
hdrJpeg = File(appContext.cacheDir.also { it.mkdirs() }, "${UUID.randomUUID()}.jpg")
|
||||||
|
val fixture = ultraHdrBitmap()
|
||||||
|
try {
|
||||||
|
hdrJpeg.outputStream().use { fixture.compress(Bitmap.CompressFormat.JPEG, 95, it) }
|
||||||
|
} finally {
|
||||||
|
fixture.recycleWithGainmap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
if (::hdrJpeg.isInitialized) hdrJpeg.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theFixtureIsAnUltraHdrJpeg() {
|
||||||
|
assertTrue("fixture lost its gain map", decodesWithGainmap(hdrJpeg))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun metadataStrippingKeepsTheGainmap() {
|
||||||
|
val stripped = MetadataStripper.stripImageMetadata(hdrJpeg.toUri(), appContext)
|
||||||
|
assertTrue("stripper did not run", stripped.stripped)
|
||||||
|
assertTrue("stripping dropped the gain map", decodesWithGainmap(File(requireNotNull(stripped.uri.path))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compressionKeepsTheGainmap() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val out = compress(hdrJpeg)
|
||||||
|
val bounds = bounds(out)
|
||||||
|
// 4000x3000 against the compressor's 640x816 target: BitmapFactory subsamples by 2.
|
||||||
|
assertEquals("the compressor did not downscale", 2000, bounds.outWidth)
|
||||||
|
assertTrue("compression dropped the gain map", decodesWithGainmap(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compressionOfARotatedPhotoKeepsTheGainmap() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
// Portrait phone photos are stored landscape plus an EXIF rotation, which the
|
||||||
|
// compressor bakes in through Bitmap.createBitmap(..., matrix, ...).
|
||||||
|
ExifInterface(hdrJpeg).apply {
|
||||||
|
setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_ROTATE_90.toString())
|
||||||
|
saveAttributes()
|
||||||
|
}
|
||||||
|
val out = compress(hdrJpeg)
|
||||||
|
val bounds = bounds(out)
|
||||||
|
assertTrue("the compressor did not rotate", bounds.outHeight > bounds.outWidth)
|
||||||
|
assertTrue("rotating dropped the gain map", decodesWithGainmap(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun coilDecodeKeepsTheGainmap() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
// Coil owns the bitmaps it returns (its memory cache may hand them out again), so
|
||||||
|
// release them by shutting the loader down rather than recycling them here.
|
||||||
|
val loader = ImageLoader.Builder(appContext).build()
|
||||||
|
try {
|
||||||
|
for (hardware in listOf(true, false)) {
|
||||||
|
val request =
|
||||||
|
ImageRequest
|
||||||
|
.Builder(appContext)
|
||||||
|
.data(hdrJpeg)
|
||||||
|
.size(400, 300)
|
||||||
|
.allowHardware(hardware)
|
||||||
|
.build()
|
||||||
|
val result = loader.execute(request) as SuccessResult
|
||||||
|
val bitmap = (result.image as BitmapImage).bitmap
|
||||||
|
assertTrue("Coil dropped the gain map (allowHardware=$hardware)", bitmap.hasGainmap())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loader.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A feed image as the app loads it: over HTTP through [OkHttpFactory] (which re-homes the
|
||||||
|
* disk-cache file so the platform ImageDecoder is reachable), then again from the memory
|
||||||
|
* cache, then from the disk cache after the memory cache is gone.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
|
@Test
|
||||||
|
fun coilCachesKeepTheGainmap() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val cacheDir = File(appContext.cacheDir, "hdr-coil-${UUID.randomUUID()}")
|
||||||
|
val bytes = hdrJpeg.readBytes()
|
||||||
|
ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")).use { server ->
|
||||||
|
serveForever(server, bytes)
|
||||||
|
val memoryCache = MemoryCache.Builder().maxSizeBytes(64L * 1024 * 1024).build()
|
||||||
|
val loader =
|
||||||
|
ImageLoader
|
||||||
|
.Builder(appContext)
|
||||||
|
.memoryCache(memoryCache)
|
||||||
|
.diskCache(DiskCache.Builder().directory(cacheDir.toOkioPath()).build())
|
||||||
|
.precision(Precision.INEXACT)
|
||||||
|
.components {
|
||||||
|
val client = OkHttpClient()
|
||||||
|
add(OkHttpFactory({ client }, DeDupeConcurrentRequestStrategy()))
|
||||||
|
}.build()
|
||||||
|
val url = "http://127.0.0.1:${server.localPort}/hdr.jpg"
|
||||||
|
val request =
|
||||||
|
ImageRequest
|
||||||
|
.Builder(appContext)
|
||||||
|
.data(url)
|
||||||
|
.size(400, 300)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
suspend fun load(expected: DataSource) {
|
||||||
|
val result = loader.execute(request)
|
||||||
|
if (result is ErrorResult) throw AssertionError("$expected load failed", result.throwable)
|
||||||
|
result as SuccessResult
|
||||||
|
assertEquals(expected, result.dataSource)
|
||||||
|
assertTrue("gain map lost on a $expected load", (result.image as BitmapImage).bitmap.hasGainmap())
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
load(DataSource.NETWORK)
|
||||||
|
load(DataSource.MEMORY_CACHE)
|
||||||
|
memoryCache.clear()
|
||||||
|
load(DataSource.DISK)
|
||||||
|
} finally {
|
||||||
|
loader.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cacheDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun compress(file: File): File {
|
||||||
|
val compressed = MediaCompressor().compress(file.toUri(), "image/jpeg", CompressorQuality.MEDIUM, appContext)
|
||||||
|
assertEquals("the compressor fell back to the original", "image/jpeg", compressed.contentType)
|
||||||
|
val out = File(requireNotNull(compressed.uri.path))
|
||||||
|
assertTrue("the compressor returned the original file", out.absolutePath != file.absolutePath)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bounds(file: File) = BitmapFactory.Options().apply { inJustDecodeBounds = true }.also { BitmapFactory.decodeFile(file.absolutePath, it) }
|
||||||
|
|
||||||
|
// Full-size decodes of a 4000x3000 photo: recycle them rather than wait on the GC between tests.
|
||||||
|
private fun decodesWithGainmap(file: File): Boolean {
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return false
|
||||||
|
return try {
|
||||||
|
bitmap.hasGainmap()
|
||||||
|
} finally {
|
||||||
|
bitmap.recycleWithGainmap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gain map is a separate bitmap; recycling the base does not free it.
|
||||||
|
private fun Bitmap.recycleWithGainmap() {
|
||||||
|
gainmap?.gainmapContents?.recycle()
|
||||||
|
recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serveForever(
|
||||||
|
server: ServerSocket,
|
||||||
|
body: ByteArray,
|
||||||
|
) = thread(isDaemon = true) {
|
||||||
|
while (!server.isClosed) {
|
||||||
|
val socket = runCatching { server.accept() }.getOrNull() ?: break
|
||||||
|
socket.use {
|
||||||
|
val input = it.getInputStream().bufferedReader()
|
||||||
|
while (input.readLine()?.isNotEmpty() == true) Unit
|
||||||
|
it.getOutputStream().apply {
|
||||||
|
write("HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: ${body.size}\r\nConnection: close\r\n\r\n".toByteArray())
|
||||||
|
write(body)
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ultraHdrBitmap(): Bitmap {
|
||||||
|
val base = createBitmap(4000, 3000).apply { eraseColor(Color.rgb(120, 140, 160)) }
|
||||||
|
val contents = createBitmap(1000, 750).apply { eraseColor(Color.rgb(200, 200, 200)) }
|
||||||
|
base.gainmap =
|
||||||
|
Gainmap(contents).apply {
|
||||||
|
setRatioMax(4f, 4f, 4f)
|
||||||
|
setDisplayRatioForFullHdr(4f)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.graphics.Gainmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.Window
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import coil3.asImage
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class HdrWindowModeTest {
|
||||||
|
@get:Rule
|
||||||
|
val rule = createComposeRule()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun requireGainmaps() {
|
||||||
|
assumeTrue("Gain maps need API 34+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hdrImage() = createBitmap(8, 8).apply { gainmap = Gainmap(createBitmap(2, 2)) }.asImage()
|
||||||
|
|
||||||
|
private fun sdrImage() = createBitmap(8, 8).asImage()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theWindowIsHdrOnlyWhileAGainmapImageIsShown() {
|
||||||
|
var window: Window? = null
|
||||||
|
var showFirst by mutableStateOf(true)
|
||||||
|
var showSecond by mutableStateOf(true)
|
||||||
|
val image = hdrImage()
|
||||||
|
|
||||||
|
rule.setContent {
|
||||||
|
window = getActivityWindow()
|
||||||
|
if (showFirst) RequestHdrFor(image, fullscreen = false)
|
||||||
|
if (showSecond) RequestHdrFor(image, fullscreen = false)
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(ActivityInfo.COLOR_MODE_HDR, window!!.colorMode)
|
||||||
|
|
||||||
|
showFirst = false
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals("one image still wants HDR", ActivityInfo.COLOR_MODE_HDR, window!!.colorMode)
|
||||||
|
|
||||||
|
showSecond = false
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, window!!.colorMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anSdrImageLeavesTheWindowAlone() {
|
||||||
|
var window: Window? = null
|
||||||
|
rule.setContent {
|
||||||
|
window = getActivityWindow()
|
||||||
|
RequestHdrFor(sdrImage(), fullscreen = false)
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, window!!.colorMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun insideADialogTheDialogWindowGoesHdr() {
|
||||||
|
var activityWindow: Window? = null
|
||||||
|
var dialogWindow: Window? = null
|
||||||
|
val image = hdrImage()
|
||||||
|
|
||||||
|
rule.setContent {
|
||||||
|
activityWindow = getActivityWindow()
|
||||||
|
Dialog(onDismissRequest = {}) {
|
||||||
|
dialogWindow = getDialogWindow()
|
||||||
|
RequestHdrFor(image, fullscreen = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertEquals(ActivityInfo.COLOR_MODE_HDR, dialogWindow!!.colorMode)
|
||||||
|
assertEquals(ActivityInfo.COLOR_MODE_DEFAULT, activityWindow!!.colorMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moreHdrImagesDoNotResendTheWindowAttributes() {
|
||||||
|
var window: Window? = null
|
||||||
|
var count by mutableStateOf(1)
|
||||||
|
val image = hdrImage()
|
||||||
|
var attributeChanges = 0
|
||||||
|
|
||||||
|
rule.setContent {
|
||||||
|
window = getActivityWindow()
|
||||||
|
repeat(count) { RequestHdrFor(image, fullscreen = false) }
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.runOnUiThread {
|
||||||
|
val original = window!!.callback
|
||||||
|
window!!.callback =
|
||||||
|
object : Window.Callback by original {
|
||||||
|
override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams?) {
|
||||||
|
attributeChanges++
|
||||||
|
original.onWindowAttributesChanged(attrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cards scrolling in and out while HDR is already on: nothing for the window to change.
|
||||||
|
count = 4
|
||||||
|
rule.waitForIdle()
|
||||||
|
count = 2
|
||||||
|
rule.waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(0, attributeChanges)
|
||||||
|
assertEquals(ActivityInfo.COLOR_MODE_HDR, window!!.colorMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -298,7 +298,7 @@ class AuthCoordinator(
|
|||||||
relayUrl: NormalizedRelayUrl,
|
relayUrl: NormalizedRelayUrl,
|
||||||
): EventTemplate<RelayAuthEvent> {
|
): EventTemplate<RelayAuthEvent> {
|
||||||
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
||||||
val authTag = account.buzzAttestation.authTag() ?: return template
|
val authTag = account.buzzAttestation.authTag(template.createdAt) ?: return template
|
||||||
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.Window
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import coil3.BitmapImage
|
||||||
|
import coil3.Image
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The images on one window that currently want HDR, and how much headroom each asks for.
|
||||||
|
*
|
||||||
|
* An Ultra HDR photo (a JPEG with a gain map) only renders brighter than SDR white while its
|
||||||
|
* window is in [ActivityInfo.COLOR_MODE_HDR]; otherwise the platform silently draws the SDR base
|
||||||
|
* image. The window is shared by every card in a feed, so HDR stays on until the last image
|
||||||
|
* asking for it leaves composition, and then the window gets its [originalColorMode] back.
|
||||||
|
*/
|
||||||
|
class HdrRequests(
|
||||||
|
val originalColorMode: Int,
|
||||||
|
) {
|
||||||
|
private val headrooms = mutableListOf<Float>()
|
||||||
|
|
||||||
|
val wantsHdr: Boolean get() = headrooms.isNotEmpty()
|
||||||
|
|
||||||
|
/** [UNCAPPED] if any request is uncapped or there are none, else the largest cap asked for. */
|
||||||
|
val headroom: Float get() = if (UNCAPPED in headrooms) UNCAPPED else headrooms.maxOrNull() ?: UNCAPPED
|
||||||
|
|
||||||
|
fun add(headroom: Float) {
|
||||||
|
headrooms.add(headroom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops one request for [headroom]; equal requests are interchangeable. */
|
||||||
|
fun remove(headroom: Float) {
|
||||||
|
headrooms.remove(headroom)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** `Window.setDesiredHdrHeadroom`'s "no preference": the display's full HDR range. */
|
||||||
|
const val UNCAPPED = 0f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed cards share the screen with SDR text and chrome, which full HDR brightness makes
|
||||||
|
* look dim and grey. Cap them; the fullscreen viewer asks for [UNCAPPED].
|
||||||
|
*/
|
||||||
|
const val FEED_HEADROOM = 2f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main-thread only: composition and disposal both run there.
|
||||||
|
private val windowRequests = WeakHashMap<Window, HdrRequests>()
|
||||||
|
|
||||||
|
// Each setter dispatches the window attributes to the window manager even when the value is
|
||||||
|
// unchanged, and cards scroll in and out of a feed constantly: only write what actually changed.
|
||||||
|
private fun Window.applyHdr(requests: HdrRequests) {
|
||||||
|
val mode = if (requests.wantsHdr) ActivityInfo.COLOR_MODE_HDR else requests.originalColorMode
|
||||||
|
if (colorMode != mode) colorMode = mode
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||||
|
if (desiredHdrHeadroom != requests.headroom) desiredHdrHeadroom = requests.headroom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Image.hasGainmap(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && (this as? BitmapImage)?.bitmap?.hasGainmap() == true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the hosting window (the dialog's own window inside a `Dialog`) into HDR mode while this
|
||||||
|
* is in composition and [image] carries a gain map, so an Ultra HDR photo shows its highlights.
|
||||||
|
* A [fullscreen] image gets the display's full HDR range; a feed card is capped.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RequestHdrFor(
|
||||||
|
image: Image,
|
||||||
|
fullscreen: Boolean,
|
||||||
|
) {
|
||||||
|
if (!remember(image) { image.hasGainmap() }) return
|
||||||
|
val window = getDialogWindow() ?: getActivityWindow() ?: return
|
||||||
|
val headroom = if (fullscreen) HdrRequests.UNCAPPED else HdrRequests.FEED_HEADROOM
|
||||||
|
|
||||||
|
DisposableEffect(window, headroom) {
|
||||||
|
val requests = windowRequests.getOrPut(window) { HdrRequests(window.colorMode) }
|
||||||
|
requests.add(headroom)
|
||||||
|
window.applyHdr(requests)
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
requests.remove(headroom)
|
||||||
|
window.applyHdr(requests)
|
||||||
|
if (!requests.wantsHdr) windowRequests.remove(window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-20
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.components
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
@@ -44,6 +45,7 @@ import androidx.compose.foundation.pager.rememberPagerState
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -182,20 +184,29 @@ fun ZoomableImageDialog(
|
|||||||
val activityWindow = getActivityWindow()
|
val activityWindow = getActivityWindow()
|
||||||
val dialogWindow = getDialogWindow()
|
val dialogWindow = getDialogWindow()
|
||||||
|
|
||||||
if (activityWindow != null && dialogWindow != null) {
|
// Keyed, not inline: each assignment below is a window-manager round trip, so it must run
|
||||||
// Preserve any brightness override already applied to the dialog window (e.g. by the
|
// on orientation changes only, not on every recomposition of this content.
|
||||||
// fullscreen swipe controls). This block re-runs on recomposition (orientation change
|
DisposableEffect(orientation, activityWindow, dialogWindow) {
|
||||||
// re-reads `orientation` above), and copying the activity attributes would otherwise
|
if (activityWindow != null && dialogWindow != null) {
|
||||||
// reset screenBrightness and snap the user's brightness back mid-session.
|
// Preserve what the dialog window owns: the brightness override applied by the
|
||||||
val currentBrightness = dialogWindow.attributes.screenBrightness
|
// fullscreen swipe controls, and the HDR mode [RequestHdrFor] manages for the
|
||||||
val attributes = WindowManager.LayoutParams()
|
// image on screen. Copying the activity's instead would snap brightness back
|
||||||
attributes.copyFrom(activityWindow.attributes)
|
// mid-session and cap or force HDR by whatever the feed behind happens to show.
|
||||||
attributes.type = dialogWindow.attributes.type
|
val current = dialogWindow.attributes
|
||||||
// Disable the system dim so the thumbnail stays visible behind the growing dialog.
|
val attributes = WindowManager.LayoutParams()
|
||||||
attributes.dimAmount = 0f
|
attributes.copyFrom(activityWindow.attributes)
|
||||||
attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv()
|
attributes.type = current.type
|
||||||
attributes.screenBrightness = currentBrightness
|
// Disable the system dim so the thumbnail stays visible behind the growing dialog.
|
||||||
dialogWindow.attributes = attributes
|
attributes.dimAmount = 0f
|
||||||
|
attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv()
|
||||||
|
attributes.screenBrightness = current.screenBrightness
|
||||||
|
attributes.colorMode = current.colorMode
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||||
|
attributes.desiredHdrHeadroom = current.desiredHdrHeadroom
|
||||||
|
}
|
||||||
|
dialogWindow.attributes = attributes
|
||||||
|
}
|
||||||
|
onDispose {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go fully immersive while the full-screen media viewer is open. Applies to full-screen
|
// Go fully immersive while the full-screen media viewer is open. Applies to full-screen
|
||||||
@@ -215,9 +226,9 @@ fun ZoomableImageDialog(
|
|||||||
allImages = allImages,
|
allImages = allImages,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
sourceBounds = sourceBounds,
|
sourceBounds = sourceBounds,
|
||||||
imageBounds = imageBounds,
|
imageBounds = { imageBounds },
|
||||||
onImageBoundsChanged = updateImageBounds,
|
onImageBoundsChanged = updateImageBounds,
|
||||||
currentZoomState = currentZoomState,
|
currentZoomState = { currentZoomState },
|
||||||
onZoomStateChanged = { currentZoomState = it },
|
onZoomStateChanged = { currentZoomState = it },
|
||||||
progress = progressProvider,
|
progress = progressProvider,
|
||||||
onDismiss = dismissWithAnimation,
|
onDismiss = dismissWithAnimation,
|
||||||
@@ -232,9 +243,9 @@ private fun DialogContent(
|
|||||||
allImages: ImmutableList<BaseMediaContent>,
|
allImages: ImmutableList<BaseMediaContent>,
|
||||||
imageUrl: BaseMediaContent,
|
imageUrl: BaseMediaContent,
|
||||||
sourceBounds: Rect?,
|
sourceBounds: Rect?,
|
||||||
imageBounds: Rect?,
|
imageBounds: () -> Rect?,
|
||||||
onImageBoundsChanged: (Rect) -> Unit,
|
onImageBoundsChanged: (Rect) -> Unit,
|
||||||
currentZoomState: ZoomState?,
|
currentZoomState: () -> ZoomState?,
|
||||||
onZoomStateChanged: (ZoomState) -> Unit,
|
onZoomStateChanged: (ZoomState) -> Unit,
|
||||||
progress: () -> Float,
|
progress: () -> Float,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -271,12 +282,12 @@ private fun DialogContent(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
val src = sourceBounds
|
val src = sourceBounds
|
||||||
val img = imageBounds
|
val img = imageBounds()
|
||||||
if (src != null && img != null && src.hasArea() && img.hasArea()) {
|
if (src != null && img != null && src.hasArea() && img.hasArea()) {
|
||||||
// Account for user-applied zoom: the exit animation must start from
|
// Account for user-applied zoom: the exit animation must start from
|
||||||
// the visible bounds, not the unzoomed layout bounds — otherwise
|
// the visible bounds, not the unzoomed layout bounds — otherwise
|
||||||
// dismissing a zoomed-in image jumps.
|
// dismissing a zoomed-in image jumps.
|
||||||
val zoomed = img.zoomedBy(currentZoomState)
|
val zoomed = img.zoomedBy(currentZoomState())
|
||||||
// Uniform scale so non-square images keep their aspect ratio during
|
// Uniform scale so non-square images keep their aspect ratio during
|
||||||
// the grow animation. The image covers the source rect; the overflow
|
// the grow animation. The image covers the source rect; the overflow
|
||||||
// is clipped below.
|
// is clipped below.
|
||||||
|
|||||||
+8
-4
@@ -457,9 +457,11 @@ fun LocalImageView(
|
|||||||
is AsyncImagePainter.State.Success -> {
|
is AsyncImagePainter.State.Success -> {
|
||||||
SubcomposeAsyncImageContent(loadedImageModifier)
|
SubcomposeAsyncImageContent(loadedImageModifier)
|
||||||
|
|
||||||
|
val image = (state as AsyncImagePainter.State.Success).result.image
|
||||||
|
RequestHdrFor(image, fullscreen = fullResolution)
|
||||||
|
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val drawable = (state as AsyncImagePainter.State.Success).result.image
|
MediaAspectRatioCache.add(content.localJavaFile.toString(), image.width, image.height)
|
||||||
MediaAspectRatioCache.add(content.localJavaFile.toString(), drawable.width, drawable.height)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
content.isVerified?.let {
|
content.isVerified?.let {
|
||||||
@@ -599,9 +601,11 @@ fun UrlImageView(
|
|||||||
|
|
||||||
ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd))
|
ShowHashAnimated(content, controllerVisible, Modifier.align(Alignment.TopEnd))
|
||||||
|
|
||||||
|
val image = (state as AsyncImagePainter.State.Success).result.image
|
||||||
|
RequestHdrFor(image, fullscreen = fullResolution)
|
||||||
|
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val drawable = (state as AsyncImagePainter.State.Success).result.image
|
MediaAspectRatioCache.add(content.url, image.width, image.height)
|
||||||
MediaAspectRatioCache.add(content.url, drawable.width, drawable.height)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -61,7 +61,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
|||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.isBuzzEditableBy
|
||||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel
|
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel
|
||||||
@@ -283,13 +285,25 @@ fun ChatMessageActionSheet(
|
|||||||
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
|
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
|
||||||
|
|
||||||
// Editing my own chat message. Two surfaces publish an edit today, gated by type:
|
// Editing my own chat message. Two surfaces publish an edit today, gated by type:
|
||||||
// - Buzz: kind-40002 stream message → a kind-40003 edit.
|
// - Buzz: kind-9 message in a Buzz-dialect relay group (or a legacy kind-40002 one)
|
||||||
|
// → a kind-40003 edit, by its author or the author's agent owner.
|
||||||
// - Concord: kind-9 channel message or kind-1111 thread reply (carries a
|
// - Concord: kind-9 channel message or kind-1111 thread reply (carries a
|
||||||
// ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane.
|
// ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane.
|
||||||
// Both restrict to my own messages; a note is only ever one of the two, so at
|
// Both restrict to my own messages; a note is only ever one of the two, so at
|
||||||
// most one tile shows and both route through the same edit callback.
|
// most one tile shows and both route through the same edit callback.
|
||||||
val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
||||||
val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine
|
// Buzz lets the owner of an agent edit the agent's messages too, and credits a
|
||||||
|
// relay-signed message to the member it names, so its own rule decides.
|
||||||
|
val canEditBuzz =
|
||||||
|
onWantsToEditChatMessage != null &&
|
||||||
|
(
|
||||||
|
note.event is StreamMessageV2Event ||
|
||||||
|
(
|
||||||
|
note.event is ChatEvent &&
|
||||||
|
note.inGatherers?.any { it is RelayGroupChannel && BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } == true
|
||||||
|
)
|
||||||
|
) &&
|
||||||
|
note.isBuzzEditableBy(accountViewModel.userProfile().pubkeyHex)
|
||||||
val canEditConcord =
|
val canEditConcord =
|
||||||
onWantsToEditChatMessage != null &&
|
onWantsToEditChatMessage != null &&
|
||||||
(note.event is ChatEvent || note.event is CommentEvent) &&
|
(note.event is ChatEvent || note.event is CommentEvent) &&
|
||||||
|
|||||||
+1
-1
@@ -126,7 +126,7 @@ fun ChatroomMessageCompose(
|
|||||||
// reply quotes inside a DM, where the target is simply older than the loaded window (see
|
// reply quotes inside a DM, where the target is simply older than the loaded window (see
|
||||||
// LoadingReplyNote). Null keeps the default blank for every other caller.
|
// LoadingReplyNote). Null keeps the default blank for every other caller.
|
||||||
onBlank: (@Composable () -> Unit)? = null,
|
onBlank: (@Composable () -> Unit)? = null,
|
||||||
// Edit my own chat message on surfaces that support it (Buzz kind-40002 → 40003,
|
// Edit my own chat message on surfaces that support it (Buzz kind-9 or legacy 40002 → 40003,
|
||||||
// Concord kind-9 → 1010). Null for chat surfaces without message editing, which hides
|
// Concord kind-9 → 1010). Null for chat surfaces without message editing, which hides
|
||||||
// the action.
|
// the action.
|
||||||
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
|
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
|
||||||
|
|||||||
+40
-10
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -28,10 +30,15 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel
|
||||||
|
import com.vitorpamplona.amethyst.commons.buzz.ui.rememberBuzzContextualName
|
||||||
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
|
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
|
||||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
@@ -44,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
|
|||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier
|
import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DrawAuthorInfo(
|
fun DrawAuthorInfo(
|
||||||
@@ -54,8 +62,15 @@ fun DrawAuthorInfo(
|
|||||||
// A geohash chat resolves the message's `n` nickname here (throwaway keys have no profile);
|
// A geohash chat resolves the message's `n` nickname here (throwaway keys have no profile);
|
||||||
// null everywhere else, so authors render from their profile as usual.
|
// null everywhere else, so authors render from their profile as usual.
|
||||||
val nameOverride = LocalChatDisplayNameResolver.current?.invoke(baseNote)
|
val nameOverride = LocalChatDisplayNameResolver.current?.invoke(baseNote)
|
||||||
|
// In a Buzz channel two members may share a name; Buzz tells them apart there.
|
||||||
|
val buzzChannel =
|
||||||
|
remember(baseNote) {
|
||||||
|
baseNote.inGatherers
|
||||||
|
?.firstNotNullOfOrNull { it as? RelayGroupChannel }
|
||||||
|
?.takeIf { BuzzRelayDialect.isBuzz(it.groupId.relayUrl) }
|
||||||
|
}
|
||||||
baseNote.author?.let {
|
baseNote.author?.let {
|
||||||
WatchAndDisplayUser(it, nameOverride, accountViewModel, nav)
|
WatchAndDisplayUser(it, nameOverride, buzzChannel, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,14 +95,20 @@ fun authorNameColorFor(
|
|||||||
private fun WatchAndDisplayUser(
|
private fun WatchAndDisplayUser(
|
||||||
author: User,
|
author: User,
|
||||||
nameOverride: String?,
|
nameOverride: String?,
|
||||||
|
buzzChannel: RelayGroupChannel?,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val userState by observeUserInfo(author, accountViewModel)
|
val userState by observeUserInfo(author, accountViewModel)
|
||||||
val nickname by observeUserDisplayNickname(author, accountViewModel)
|
val nickname by observeUserDisplayNickname(author, accountViewModel)
|
||||||
val petName = nickname?.petName
|
val petName = nickname?.petName
|
||||||
// A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key.
|
val profileName = userState?.info?.bestName()
|
||||||
val displayName = nameOverride ?: petName ?: userState?.info?.bestName()
|
// Buzz's contextual name ("Alice’s Honey", "Honey · 7xk2") when the channel has a namesake.
|
||||||
|
val contextual = buzzChannel?.let { rememberBuzzContextualName(it, author, accountViewModel) }?.name
|
||||||
|
val qualified = contextual != null && profileName != null && contextual != IdentityNamePolicy.trim(profileName)
|
||||||
|
// A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key, and a
|
||||||
|
// nickname the account set wins over everything else.
|
||||||
|
val displayName = nameOverride ?: petName ?: (if (qualified) contextual else profileName)
|
||||||
|
|
||||||
val isLightTheme = MaterialTheme.colorScheme.isLight
|
val isLightTheme = MaterialTheme.colorScheme.isLight
|
||||||
val nameColor =
|
val nameColor =
|
||||||
@@ -114,13 +135,22 @@ private fun WatchAndDisplayUser(
|
|||||||
ObserveAndRenderUserCards(author, Size20dp, Modifier.align(Alignment.BottomCenter), accountViewModel)
|
ObserveAndRenderUserCards(author, Size20dp, Modifier.align(Alignment.BottomCenter), accountViewModel)
|
||||||
},
|
},
|
||||||
name = {
|
name = {
|
||||||
CreateTextWithEmoji(
|
Row(
|
||||||
text = displayName ?: author.pubkeyDisplayHex(),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
color = nameColor,
|
) {
|
||||||
maxLines = 1,
|
CreateTextWithEmoji(
|
||||||
fontWeight = FontWeight.Bold,
|
text = displayName ?: author.pubkeyDisplayHex(),
|
||||||
)
|
tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
|
||||||
|
color = nameColor,
|
||||||
|
maxLines = 1,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
// An agent says so, and whose it is (its owner comes from the NIP-OA tag on its kind 0),
|
||||||
|
// unless its contextual name already does.
|
||||||
|
BuzzAgentLabel(author, userState?.nipOaOwner, accountViewModel, showOwner = !qualified)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
||||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||||
@@ -101,7 +102,15 @@ fun RenderBuzzEditedNote(
|
|||||||
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
// Render with the tags the edit leaves the message with (Buzz's applyEditTagOverlay): the edit's
|
||||||
|
// attachments and custom emoji, the original's channel/thread tags.
|
||||||
|
val tags =
|
||||||
|
remember(note.event, editNote.event) {
|
||||||
|
note.event
|
||||||
|
?.tags
|
||||||
|
?.let { BuzzEditTagOverlay.apply(it, editNote.event?.tags) }
|
||||||
|
?.toImmutableListOfLists() ?: EmptyTagList
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
TranslatableRichTextViewer(
|
TranslatableRichTextViewer(
|
||||||
|
|||||||
+12
-2
@@ -41,6 +41,8 @@ import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer
|
|||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.canonicalBuzzChannelName
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.isValidBuzzChannelName
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.newBuzzChannelId
|
import com.vitorpamplona.quartz.buzz.workspace.newBuzzChannelId
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
@@ -127,7 +129,13 @@ class RelayGroupMetadataViewModel : ViewModel() {
|
|||||||
var touched by mutableStateOf(false)
|
var touched by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val canPost by derivedStateOf { !isWorking && name.value.text.isNotBlank() }
|
/**
|
||||||
|
* A Buzz relay strips leading `#`s and whitespace from a channel name and refuses one that ends
|
||||||
|
* up empty, so on Buzz a name like `"# #"` can't be submitted at all.
|
||||||
|
*/
|
||||||
|
val canPost by derivedStateOf {
|
||||||
|
!isWorking && name.value.text.isNotBlank() && (!isBuzzRelay || isValidBuzzChannelName(name.value.text))
|
||||||
|
}
|
||||||
|
|
||||||
fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank()
|
fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank()
|
||||||
|
|
||||||
@@ -239,7 +247,9 @@ class RelayGroupMetadataViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun publish() {
|
private suspend fun publish() {
|
||||||
val name = name.value.text.trim()
|
// Send Buzz the name it would store anyway (no leading `#`s), so our optimistic copy and
|
||||||
|
// the relay's 39000 agree.
|
||||||
|
val name = if (isBuzzRelay) canonicalBuzzChannelName(name.value.text) else name.value.text.trim()
|
||||||
val about =
|
val about =
|
||||||
about.value.text
|
about.value.text
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
+55
@@ -40,7 +40,9 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -53,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
@@ -69,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_channel_archive
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete
|
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete_confirm
|
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete_confirm
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_unarchive
|
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_unarchive
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_huddle_live
|
||||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||||
import com.vitorpamplona.amethyst.commons.resources.join
|
import com.vitorpamplona.amethyst.commons.resources.join
|
||||||
import com.vitorpamplona.amethyst.commons.resources.leave
|
import com.vitorpamplona.amethyst.commons.resources.leave
|
||||||
@@ -97,6 +101,10 @@ import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
|
|||||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RelayGroupTopBar(
|
fun RelayGroupTopBar(
|
||||||
@@ -154,6 +162,7 @@ fun RelayGroupTopBar(
|
|||||||
var showJoinCode by remember { mutableStateOf(false) }
|
var showJoinCode by remember { mutableStateOf(false) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) }
|
val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) }
|
||||||
|
val huddleLive by observeBuzzHuddleLive(channel, enabled = isBuzzRelay && membership.isMember(), accountViewModel)
|
||||||
|
|
||||||
TopBarExtensibleWithBackButton(
|
TopBarExtensibleWithBackButton(
|
||||||
title = {
|
title = {
|
||||||
@@ -207,6 +216,20 @@ fun RelayGroupTopBar(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (huddleLive) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.Headphones,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringRes(Res.string.buzz_huddle_live),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -556,6 +579,38 @@ private fun RoleBadge(membership: RelayGroupMembership) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a Buzz huddle is live in [channel] right now. While [enabled] (a Buzz relay we are a
|
||||||
|
* member of - the relay answers liveness only for an authorized `#h`), re-asks the relay every
|
||||||
|
* [HUDDLE_LIVENESS_REFRESH_MS] as Buzz's desktop does; a session that stops being reported ages out
|
||||||
|
* of [BuzzHuddleLivenessState] and the indicator drops.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun observeBuzzHuddleLive(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
enabled: Boolean,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
): State<Boolean> {
|
||||||
|
val seen by BuzzHuddleLivenessState.flow.collectAsStateWithLifecycle()
|
||||||
|
var now by remember { mutableLongStateOf(TimeUtils.now()) }
|
||||||
|
LaunchedEffect(channel.groupId, enabled) {
|
||||||
|
if (!enabled) return@LaunchedEffect
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
accountViewModel.account.relayGroups.refreshBuzzHuddleLiveness(channel)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.w("RelayGroupTopBar", "Huddle liveness refresh failed", e)
|
||||||
|
}
|
||||||
|
now = TimeUtils.now()
|
||||||
|
delay(HUDDLE_LIVENESS_REFRESH_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remember(channel.groupId, enabled) { derivedStateOf { enabled && BuzzHuddleLivenessState.liveSessions(channel.groupId.id, now, seen).isNotEmpty() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HUDDLE_LIVENESS_REFRESH_MS = 10_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this Buzz channel has a canvas (kind 40100) in cache, recomposing when one lands.
|
* Whether this Buzz channel has a canvas (kind 40100) in cache, recomposing when one lands.
|
||||||
*
|
*
|
||||||
|
|||||||
+61
-42
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
|
|||||||
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
|
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
|
||||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit
|
||||||
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
|
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
@@ -76,12 +77,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
|
||||||
import com.vitorpamplona.quartz.buzz.stream.mentions
|
import com.vitorpamplona.quartz.buzz.stream.mentions
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
|
||||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
||||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||||
@@ -114,6 +114,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.notify
|
|||||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
|
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
|
||||||
|
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||||
@@ -177,11 +178,16 @@ open class ChannelNewMessageViewModel :
|
|||||||
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
|
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
|
||||||
val replyMode = mutableStateOf(ReplyMode.INLINE)
|
val replyMode = mutableStateOf(ReplyMode.INLINE)
|
||||||
|
|
||||||
// When set, the composer is editing an existing Buzz stream message (kind 40002):
|
// When set, the composer is editing an existing Buzz message (kind 9, or a legacy 40002):
|
||||||
// the next send publishes a kind-40003 edit targeting this note instead of a new
|
// the next send publishes a kind-40003 edit targeting this note instead of a new
|
||||||
// message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage).
|
// message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage).
|
||||||
val editingBuzzMessage = mutableStateOf<Note?>(null)
|
val editingBuzzMessage = mutableStateOf<Note?>(null)
|
||||||
|
|
||||||
|
// The custom emoji the message being edited currently renders with. Buzz replaces a message's
|
||||||
|
// emoji set with the edit's whenever the edit carries any, so an edit re-sends the ones still
|
||||||
|
// used in the text alongside any newly picked ones.
|
||||||
|
private var editingBuzzEmojis: List<EmojiUrlTag> = emptyList()
|
||||||
|
|
||||||
// Explicit @-mentions resolved by the last createTemplate, used by sendPostSync for the Buzz
|
// Explicit @-mentions resolved by the last createTemplate, used by sendPostSync for the Buzz
|
||||||
// auto-invite. Kept off the draft path on purpose (see createTemplate / sendPostSync).
|
// auto-invite. Kept off the draft path on purpose (see createTemplate / sendPostSync).
|
||||||
private var pendingBuzzInviteMentions: List<User> = emptyList()
|
private var pendingBuzzInviteMentions: List<User> = emptyList()
|
||||||
@@ -295,21 +301,30 @@ open class ChannelNewMessageViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks
|
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text (its newest edit,
|
||||||
* the next send as a kind-40003 edit of it. Editing and replying are mutually
|
* if it has one) and marks the next send as a kind-40003 edit of it. Editing and replying are
|
||||||
* exclusive, so any pending reply is cleared. The caller gates this to the user's own
|
* mutually exclusive, so any pending reply is cleared. The caller gates this to the user's
|
||||||
* kind-40002 messages on a Buzz relay.
|
* own kind-9 (or legacy 40002) messages on a Buzz relay.
|
||||||
*/
|
*/
|
||||||
fun editBuzzMessage(note: Note) {
|
fun editBuzzMessage(note: Note) {
|
||||||
replyTo.value = null
|
replyTo.value = null
|
||||||
replyMode.value = ReplyMode.INLINE
|
replyMode.value = ReplyMode.INLINE
|
||||||
editingBuzzMessage.value = note
|
editingBuzzMessage.value = note
|
||||||
message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "")
|
val latestEdit = note.latestBuzzEdit()?.event
|
||||||
|
message.setTextAndPlaceCursorAtEnd(latestEdit?.content ?: note.event?.content ?: "")
|
||||||
|
// Start from what the message shows now: an edit fully replaces its attachments on Buzz,
|
||||||
|
// so the ones still referenced in the text must go out again with the edit.
|
||||||
|
val currentTags = BuzzEditTagOverlay.apply(note.event?.tags ?: emptyArray(), latestEdit?.tags)
|
||||||
|
iMetaAttachments.reset()
|
||||||
|
iMetaAttachments.addAll(currentTags.imetas())
|
||||||
|
editingBuzzEmojis = currentTags.emojis()
|
||||||
draftTag.newVersion()
|
draftTag.newVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearBuzzEdit() {
|
fun clearBuzzEdit() {
|
||||||
editingBuzzMessage.value = null
|
editingBuzzMessage.value = null
|
||||||
|
editingBuzzEmojis = emptyList()
|
||||||
|
iMetaAttachments.reset()
|
||||||
message.setTextAndPlaceCursorAtEnd("")
|
message.setTextAndPlaceCursorAtEnd("")
|
||||||
draftTag.newVersion()
|
draftTag.newVersion()
|
||||||
}
|
}
|
||||||
@@ -592,7 +607,7 @@ open class ChannelNewMessageViewModel :
|
|||||||
val pk = user.pubkeyHex
|
val pk = user.pubkeyHex
|
||||||
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
|
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
|
||||||
try {
|
try {
|
||||||
accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList())
|
accountViewModel.account.relayGroups.addRelayGroupUser(channel, pk)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}", e)
|
Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}", e)
|
||||||
@@ -640,7 +655,7 @@ open class ChannelNewMessageViewModel :
|
|||||||
// Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's
|
// Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's
|
||||||
// registry — a minichat comment sent to a workspace channel would be refused
|
// registry — a minichat comment sent to a workspace channel would be refused
|
||||||
// by the relay AFTER the composer already cleared. Buzz replies always go
|
// by the relay AFTER the composer already cleared. Buzz replies always go
|
||||||
// through the 40002 branch with its thread markers instead.
|
// through the Buzz kind-9 branch with its thread markers instead.
|
||||||
val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl))
|
val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl))
|
||||||
val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event
|
val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event
|
||||||
if (minichatParent != null) {
|
if (minichatParent != null) {
|
||||||
@@ -804,55 +819,59 @@ open class ChannelNewMessageViewModel :
|
|||||||
channel is RelayGroupChannel &&
|
channel is RelayGroupChannel &&
|
||||||
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
||||||
editingBuzzMessage.value != null -> {
|
editingBuzzMessage.value != null -> {
|
||||||
// Buzz edit (kind 40003): replaces the text of an existing kind-40002 message.
|
// Buzz edit (kind 40003): replaces the text of an existing Buzz message.
|
||||||
// build() sets the group's `h` tag plus the `e` tag pointing at the edited
|
// build() sets the group's `h` tag plus the `e` tag pointing at the edited
|
||||||
// message; content is the replacement text. Kept minimal to mirror Buzz's own
|
// message; content is the replacement text. Kept minimal to mirror Buzz's own
|
||||||
// `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author
|
// `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author
|
||||||
// match. LocalCache overlays the newest edit last-write-wins, so the edited row
|
// match. LocalCache overlays the newest edit last-write-wins, so the edited row
|
||||||
// re-renders with this content.
|
// re-renders with this content.
|
||||||
|
// Buzz's clients render an edit with the original's tags overlaid by the edit's
|
||||||
|
// (BuzzEditTagOverlay): attachments come ONLY from the edit, and custom emoji from
|
||||||
|
// the edit whenever it has any. So the edit carries every attachment and emoji the
|
||||||
|
// new text still uses, like Buzz's own `build_message_edit`, or they would vanish.
|
||||||
val target = editingBuzzMessage.value!!
|
val target = editingBuzzMessage.value!!
|
||||||
|
val editEmojis =
|
||||||
|
(emojis + editingBuzzEmojis.filter { tagger.message.contains(":${it.code}:") })
|
||||||
|
.distinctBy { it.code }
|
||||||
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message) {
|
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message) {
|
||||||
// Carry `p` mentions for anyone cited in the edited text, so a mention added
|
// Carry `p` mentions for anyone cited in the edited text, so a mention added
|
||||||
// (or kept) by an edit still notifies the member and resolves its `nostr:` ref.
|
// (or kept) by an edit still notifies the member and resolves its `nostr:` ref.
|
||||||
mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty())
|
mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty())
|
||||||
|
imetas(usedAttachments)
|
||||||
|
emojis(editEmojis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
|
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
|
||||||
// Buzz workspace message: the native kind is 40002 (stream message v2)
|
// Buzz workspace message: a kind-9 chat in Buzz's tag shape (`build_message` in
|
||||||
// scoped with the group's `h` tag; Buzz threads replies with NIP-10
|
// buzz-sdk builders.rs), which is what Buzz's own desktop, mobile and CLI clients
|
||||||
// marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"],
|
// write. Kind 40002 is only read now, for older messages. Replies thread with NIP-10
|
||||||
// collapsing to a single "reply" when the parent IS the root — mirrors
|
// marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"], collapsing to
|
||||||
// thread_tags in buzz-sdk builders.rs) plus a `p` notify to the parent
|
// a single "reply" when the parent IS the root) plus a `p` notify to the parent
|
||||||
// author. The dialect check comes from BuzzRelayDialect (marked off
|
// author. The dialect check comes from BuzzRelayDialect (marked off verified Buzz
|
||||||
// verified Buzz events), not a channel subtype: channel instances are
|
// events), not a channel subtype: channel instances are captured by screens for
|
||||||
// captured by screens for their whole life, so the dialect must be
|
// their whole life, so the dialect must be able to flip mid-session without
|
||||||
// able to flip mid-session without swapping objects.
|
// swapping objects.
|
||||||
// Reply routing (Buzz has no kind-1111): an INLINE reply is flagged `broadcast` so it stays
|
// Reply routing (Buzz has no kind-1111): an INLINE reply is flagged `broadcast` so it stays
|
||||||
// a flat timeline sibling; a MINICHAT reply omits it so the thread markers pull it into the
|
// a flat timeline sibling; a MINICHAT reply omits it so the thread markers pull it into the
|
||||||
// message's minichat (mirrors block/buzz's broadcast-vs-thread split). A non-reply is neither.
|
// message's minichat (mirrors block/buzz's broadcast-vs-thread split). A non-reply is neither.
|
||||||
val broadcastReply = replyTo.value != null && replyMode.value == ReplyMode.INLINE
|
val parent = replyTo.value
|
||||||
StreamMessageV2Event.build(channel.groupId.id, tagger.message, broadcast = broadcastReply) {
|
// The parent's resolved root when it is itself a reply (a direct reply's collapsed form
|
||||||
replyTo.value?.let { parent ->
|
// carries the root as its "reply" marker), else the parent starts the thread. A parent
|
||||||
// The parent's root marker (when it is itself a nested reply),
|
// with only a `root` marker counts as top-level: Buzz's relay validates ancestry that
|
||||||
// else the parent's OWN reply target (a direct reply's collapsed
|
// way and rejects a mis-derived root.
|
||||||
// form carries the root as its "reply" marker — Buzz's relay
|
val threadRoot = parent?.let { it.event?.tags?.buzzThreadRootForReplyTo(it.idHex) ?: it.idHex }
|
||||||
// validates ancestry and rejects a mis-derived root), else the
|
BuzzChatMessage.build(
|
||||||
// parent starts the thread.
|
channelId = channel.groupId.id,
|
||||||
val parentTags = parent.event?.tags
|
content = tagger.message,
|
||||||
val root =
|
threadRoot = threadRoot,
|
||||||
parentTags?.buzzThreadRoot()
|
replyTo = parent?.idHex,
|
||||||
?: parentTags?.buzzThreadReply()
|
|
||||||
?: parent.idHex
|
|
||||||
buzzThread(root, parent.idHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// `p` mentions for everyone cited in the body (plus the reply target, which the
|
// `p` mentions for everyone cited in the body (plus the reply target, which the
|
||||||
// tagger seeds into pTags) so a named member is notified and the Buzz relay can
|
// tagger seeds into pTags) so a named member is notified and the Buzz relay can
|
||||||
// resolve the `nostr:` reference — mirrors the mention p-tags every other chat
|
// resolve the `nostr:` reference. Deduplicated, so the reply author isn't doubled.
|
||||||
// kind above emits. Deduplicated by mentions(), so the reply author isn't doubled.
|
mentions = tagger.pTags?.map { it.pubkeyHex }.orEmpty(),
|
||||||
mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty())
|
broadcast = parent != null && replyMode.value == ReplyMode.INLINE,
|
||||||
|
) {
|
||||||
hashtags(findHashtags(tagger.message))
|
hashtags(findHashtags(tagger.message))
|
||||||
references(findURLs(tagger.message))
|
references(findURLs(tagger.message))
|
||||||
quotes(findNostrUris(tagger.message))
|
quotes(findNostrUris(tagger.message))
|
||||||
|
|||||||
+10
@@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
@@ -152,6 +153,15 @@ fun DrawAdditionalInfo(
|
|||||||
|
|
||||||
DrawPlayName(displayName)
|
DrawPlayName(displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Buzz agent says so, and whose it is: the owner from the NIP-OA tag on its kind 0,
|
||||||
|
// which opens the owner's profile.
|
||||||
|
BuzzAgentLabel(
|
||||||
|
author = baseUser,
|
||||||
|
owner = user.nipOaOwner,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onOwnerClick = { nav.nav(Route.Profile(it)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayName != user.info.name && !user.info.name.isNullOrBlank()) {
|
if (displayName != user.info.name && !user.info.name.isNullOrBlank()) {
|
||||||
|
|||||||
@@ -23,16 +23,22 @@ package com.vitorpamplona.amethyst.model
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzzEffectiveAuthor
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.isBuzzEditableBy
|
||||||
import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit
|
import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
@@ -214,6 +220,84 @@ class BuzzWorkspaceChannelTest {
|
|||||||
assertEquals("the fix", target.latestBuzzEdit()?.event?.content)
|
assertEquals("the fix", target.latestBuzzEdit()?.event?.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEditOfAKind9MessageOverlaysIt() =
|
||||||
|
runBlocking {
|
||||||
|
val channelId = newChannelId()
|
||||||
|
// Buzz channel messages are kind 9 now; a 40003 targets them the same way.
|
||||||
|
LocalCache.checkDeletionAndConsume(streamMessage(channelId, "mark the dialect"), buzzRelay, false)
|
||||||
|
val original = signer.sign(BuzzChatMessage.build(channelId, "teh fix"))
|
||||||
|
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
|
||||||
|
val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 1))
|
||||||
|
LocalCache.checkDeletionAndConsume(edit, buzzRelay, false)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"the fix",
|
||||||
|
LocalCache
|
||||||
|
.getNoteIfExists(original.id)!!
|
||||||
|
.latestBuzzEdit()
|
||||||
|
?.event
|
||||||
|
?.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theVerifiedOwnerOfAnAgentMayEditItsMessages() =
|
||||||
|
runBlocking {
|
||||||
|
val channelId = newChannelId()
|
||||||
|
val agentKeys = KeyPair()
|
||||||
|
val ownerKeys = KeyPair()
|
||||||
|
val agent = NostrSignerInternal(agentKeys)
|
||||||
|
val owner = NostrSignerInternal(ownerKeys)
|
||||||
|
|
||||||
|
val original = agent.sign(StreamMessageV2Event.build(channelId, "agent output"))
|
||||||
|
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
|
||||||
|
val ownerEdit = owner.sign(StreamMessageEditEvent.build(channelId, original.id, "corrected by owner", createdAt = original.createdAt + 10))
|
||||||
|
LocalCache.checkDeletionAndConsume(ownerEdit, buzzRelay, false)
|
||||||
|
val target = LocalCache.getNoteIfExists(original.id)!!
|
||||||
|
|
||||||
|
// Until the agent's profile names the owner, the owner is just another pubkey.
|
||||||
|
assertNull(target.latestBuzzEdit())
|
||||||
|
assertFalse(target.isBuzzEditableBy(owner.pubKey))
|
||||||
|
|
||||||
|
// The agent's kind 0 carries the owner's NIP-OA attestation for the agent key.
|
||||||
|
val attestation = OwnerAttestation.sign(agent.pubKey, "", ownerKeys.privKey!!)
|
||||||
|
val profile = agent.sign(MetadataEvent.newUser("agent", createdAt = original.createdAt) { add(attestation.toTag()) })
|
||||||
|
LocalCache.checkDeletionAndConsume(profile, buzzRelay, false)
|
||||||
|
|
||||||
|
assertTrue(target.isBuzzEditableBy(owner.pubKey))
|
||||||
|
assertEquals("corrected by owner", target.latestBuzzEdit()?.event?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRelaySignedMessageIsEditableByTheMemberItNames() =
|
||||||
|
runBlocking {
|
||||||
|
val channelId = newChannelId()
|
||||||
|
val relayKey = NostrSignerInternal(KeyPair())
|
||||||
|
val member = NostrSignerInternal(KeyPair())
|
||||||
|
val stranger = NostrSignerInternal(KeyPair())
|
||||||
|
|
||||||
|
// The relay-signed 39000 is what tells us the relay's key.
|
||||||
|
LocalCache.checkDeletionAndConsume(relayKey.sign(GroupMetadataEvent.build(channelId, name = "ops")), buzzRelay, false)
|
||||||
|
LocalCache.checkDeletionAndConsume(streamMessage(channelId, "mark the dialect"), buzzRelay, false)
|
||||||
|
|
||||||
|
// A workflow posted on the member's behalf: relay-signed, attributed through `actor`.
|
||||||
|
val posted = relayKey.sign(BuzzChatMessage.build(channelId, "deploy done") { add(arrayOf("actor", member.pubKey)) })
|
||||||
|
LocalCache.checkDeletionAndConsume(posted, buzzRelay, false)
|
||||||
|
val target = LocalCache.getNoteIfExists(posted.id)!!
|
||||||
|
assertEquals(member.pubKey, target.buzzEffectiveAuthor())
|
||||||
|
|
||||||
|
LocalCache.checkDeletionAndConsume(stranger.sign(StreamMessageEditEvent.build(channelId, posted.id, "nope", createdAt = posted.createdAt + 1)), buzzRelay, false)
|
||||||
|
assertNull(target.latestBuzzEdit())
|
||||||
|
LocalCache.checkDeletionAndConsume(member.sign(StreamMessageEditEvent.build(channelId, posted.id, "deploy done (v2)", createdAt = posted.createdAt + 2)), buzzRelay, false)
|
||||||
|
assertEquals("deploy done (v2)", target.latestBuzzEdit()?.event?.content)
|
||||||
|
|
||||||
|
// A user-signed message can't hand its edits to someone else with an `actor` tag.
|
||||||
|
val claimed = stranger.sign(BuzzChatMessage.build(channelId, "mine") { add(arrayOf("actor", member.pubKey)) })
|
||||||
|
LocalCache.checkDeletionAndConsume(claimed, buzzRelay, false)
|
||||||
|
assertEquals(stranger.pubKey, LocalCache.getNoteIfExists(claimed.id)!!.buzzEffectiveAuthor())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun deletingAnEditUnlinksItFromTheMessage() =
|
fun deletingAnEditUnlinksItFromTheMessage() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HdrRequestsTest {
|
||||||
|
private fun requests() = HdrRequests(originalColorMode = 0)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noRequestsMeansSdr() {
|
||||||
|
val requests = requests()
|
||||||
|
assertFalse(requests.wantsHdr)
|
||||||
|
assertEquals(HdrRequests.UNCAPPED, requests.headroom)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hdrStaysOnUntilTheLastRequestLeaves() {
|
||||||
|
val requests = requests()
|
||||||
|
requests.add(2f)
|
||||||
|
requests.add(2f)
|
||||||
|
|
||||||
|
requests.remove(2f)
|
||||||
|
assertTrue(requests.wantsHdr)
|
||||||
|
|
||||||
|
requests.remove(2f)
|
||||||
|
assertFalse(requests.wantsHdr)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theLargestCappedHeadroomWins() {
|
||||||
|
val requests = requests()
|
||||||
|
requests.add(2f)
|
||||||
|
requests.add(3f)
|
||||||
|
assertEquals(3f, requests.headroom)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUncappedRequestLiftsTheCap() {
|
||||||
|
val requests = requests()
|
||||||
|
requests.add(2f)
|
||||||
|
requests.add(HdrRequests.UNCAPPED)
|
||||||
|
assertEquals(HdrRequests.UNCAPPED, requests.headroom)
|
||||||
|
|
||||||
|
requests.remove(HdrRequests.UNCAPPED)
|
||||||
|
assertEquals(2f, requests.headroom)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -597,7 +597,7 @@ screen speaks.
|
|||||||
| `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. |
|
| `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. |
|
||||||
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--picture URL] [--banner URL] [--parent GID\|--root] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads the current 39000 and changes only what you pass: picture, banner, subgroup links, other flags and unknown tags are carried over. |
|
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--picture URL] [--banner URL] [--parent GID\|--root] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads the current 39000 and changes only what you pass: picture, banner, subgroup links, other flags and unknown tags are carried over. |
|
||||||
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
|
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
|
||||||
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
|
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator] [--buzz-role owner\|admin\|member\|guest\|bot]` | Add or promote a user (9000, moderator). On Buzz, only `--buzz-role` sets a role; without it an existing member keeps theirs. |
|
||||||
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
|
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
|
||||||
| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. |
|
| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. |
|
||||||
|
|
||||||
@@ -605,13 +605,13 @@ screen speaks.
|
|||||||
|
|
||||||
[`block/buzz`](https://github.com/block/buzz) workspaces are NIP-29 groups on a Buzz
|
[`block/buzz`](https://github.com/block/buzz) workspaces are NIP-29 groups on a Buzz
|
||||||
relay, so create/join/leave still use `amy relaygroup`. These verbs cover the Buzz-native
|
relay, so create/join/leave still use `amy relaygroup`. These verbs cover the Buzz-native
|
||||||
surface: the kind:40002 stream message, the owner-attestation primitive (NIP-OA), and the
|
surface: the Buzz channel message (kind:9), the owner-attestation primitive (NIP-OA), and the
|
||||||
agent console (turn-metric aggregation + personas), all driving the same `quartz` models
|
agent console (turn-metric aggregation + personas), all driving the same `quartz` models
|
||||||
and `commons` aggregator the app uses.
|
and `commons` aggregator the app uses.
|
||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `amy buzz post RELAY GID <text>` | Post a kind:40002 stream message (Buzz-native) into a workspace. |
|
| `amy buzz post RELAY GID <text>` | Post a channel message into a workspace: kind:9 in Buzz's tag shape, what Buzz's own clients write. |
|
||||||
| `amy buzz read RELAY GID [--limit N] [--timeout SECS]` | Read the recent human-visible timeline (kinds 9 / 40002 / 40099). |
|
| `amy buzz read RELAY GID [--limit N] [--timeout SECS]` | Read the recent human-visible timeline (kinds 9 / 40002 / 40099). |
|
||||||
| `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. |
|
| `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. |
|
||||||
| `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. |
|
| `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. |
|
||||||
|
|||||||
@@ -830,7 +830,7 @@ private fun printUsage() {
|
|||||||
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
||||||
|
|
|
|
||||||
|Buzz (block/buzz agent workspaces — NIP-29 dialect):
|
|Buzz (block/buzz agent workspaces — NIP-29 dialect):
|
||||||
| buzz post RELAY GID <text> post a kind-40002 stream message
|
| buzz post RELAY GID <text> post a channel message (kind 9)
|
||||||
| buzz read RELAY GID [--limit N] read recent workspace messages
|
| buzz read RELAY GID [--limit N] read recent workspace messages
|
||||||
| buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline)
|
| buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline)
|
||||||
| buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics
|
| buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteClaim
|
||||||
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
|
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
|
||||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
||||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
@@ -63,7 +65,7 @@ import okhttp3.coroutines.executeAsync
|
|||||||
* `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the
|
* `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the
|
||||||
* same `quartz` models + `commons` aggregator the app uses. Buzz workspaces are NIP-29
|
* same `quartz` models + `commons` aggregator the app uses. Buzz workspaces are NIP-29
|
||||||
* groups, so join/leave/create still go through `amy relaygroup`; this verb group covers
|
* groups, so join/leave/create still go through `amy relaygroup`; this verb group covers
|
||||||
* the Buzz-native pieces: stream messages (40002), the owner-attestation primitive (OA),
|
* the Buzz-native pieces: channel messages (kind 9 in Buzz's shape), the owner-attestation primitive (OA),
|
||||||
* and the agent console (turn-metric aggregation + personas).
|
* and the agent console (turn-metric aggregation + personas).
|
||||||
*/
|
*/
|
||||||
object BuzzCommands {
|
object BuzzCommands {
|
||||||
@@ -345,12 +347,24 @@ object BuzzCommands {
|
|||||||
}.toString()
|
}.toString()
|
||||||
val authEvent = ctx.signer.sign(HTTPAuthorizationEvent.build(claimUrl, "POST", claimReq.encodeToByteArray()))
|
val authEvent = ctx.signer.sign(HTTPAuthorizationEvent.build(claimUrl, "POST", claimReq.encodeToByteArray()))
|
||||||
val (claimCode, claimBody) = httpPost(http, claimUrl, claimReq, authEvent.toAuthToken())
|
val (claimCode, claimBody) = httpPost(http, claimUrl, claimReq, authEvent.toAuthToken())
|
||||||
if (claimCode != 200) return Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody")
|
if (claimCode != 200) {
|
||||||
|
return when (BuzzInviteClaim.errorOf(claimBody)) {
|
||||||
|
BuzzInviteClaim.ERROR_EXHAUSTED -> Output.error("exhausted", "this invite has no uses left; ask for a new one")
|
||||||
|
BuzzInviteClaim.ERROR_EXPIRED -> Output.error("expired", "this invite has expired")
|
||||||
|
BuzzInviteClaim.ERROR_INVALID -> Output.error("invalid", "this invite is not valid for this workspace (revoked, mistyped, or never minted here)")
|
||||||
|
BuzzInviteClaim.ERROR_JOIN_POLICY_REQUIRED ->
|
||||||
|
Output.error("policy_required", "this workspace requires accepting its terms + age attestation; re-run with --accept-policy to consent")
|
||||||
|
else -> Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val result = jsonParser.parseToJsonElement(claimBody).jsonObject
|
val result = jsonParser.parseToJsonElement(claimBody).jsonObject
|
||||||
|
val status = result["status"]?.jsonPrimitive?.content
|
||||||
Output.emit(
|
Output.emit(
|
||||||
mapOf(
|
mapOf(
|
||||||
"status" to result["status"]?.jsonPrimitive?.content,
|
"status" to status,
|
||||||
|
// Distinguish a no-op claim (already a member; no invite use consumed) from a join.
|
||||||
|
"already_member" to (status == BuzzInviteClaim.STATUS_ALREADY_MEMBER),
|
||||||
"community_id" to (result["community_id"]?.jsonPrimitive?.content ?: invite.communityId),
|
"community_id" to (result["community_id"]?.jsonPrimitive?.content ?: invite.communityId),
|
||||||
"role" to (result["role"]?.jsonPrimitive?.content ?: invite.role),
|
"role" to (result["role"]?.jsonPrimitive?.content ?: invite.role),
|
||||||
"host" to invite.host,
|
"host" to invite.host,
|
||||||
@@ -389,7 +403,7 @@ object BuzzCommands {
|
|||||||
http.newCall(builder.build()).executeAsync().use { it.code to it.body.string() }
|
http.newCall(builder.build()).executeAsync().use { it.code to it.body.string() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `buzz post RELAY GID <text>` → publishes a kind-40002 stream message with an `h` tag. */
|
/** `buzz post RELAY GID <text>` → publishes a kind-9 channel message in Buzz's shape (`build_message`). */
|
||||||
private suspend fun post(
|
private suspend fun post(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
@@ -398,7 +412,7 @@ object BuzzCommands {
|
|||||||
val text = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
val text = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||||
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
|
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
|
||||||
return publishScoped(dataDir, rest, usage) { _, groupId, _ ->
|
return publishScoped(dataDir, rest, usage) { _, groupId, _ ->
|
||||||
StreamMessageV2Event.build(groupId, text)
|
BuzzChatMessage.build(groupId, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +538,9 @@ object BuzzCommands {
|
|||||||
"total_tokens" to metrics.totals.totalTokens,
|
"total_tokens" to metrics.totals.totalTokens,
|
||||||
"input_tokens" to metrics.totals.inputTokens,
|
"input_tokens" to metrics.totals.inputTokens,
|
||||||
"output_tokens" to metrics.totals.outputTokens,
|
"output_tokens" to metrics.totals.outputTokens,
|
||||||
|
// null = never reported (NIP-AM: an omitted cache component is unknown, not zero)
|
||||||
|
"cache_read_tokens" to metrics.totals.cacheReadTokens,
|
||||||
|
"cache_write_tokens" to metrics.totals.cacheWriteTokens,
|
||||||
"turns" to metrics.totalTurns,
|
"turns" to metrics.totalTurns,
|
||||||
"sessions" to metrics.totalSessions,
|
"sessions" to metrics.totalSessions,
|
||||||
"agents" to metrics.agents.size,
|
"agents" to metrics.agents.size,
|
||||||
|
|||||||
+13
-3
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.Args
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Output
|
import com.vitorpamplona.amethyst.cli.Output
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLES
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
@@ -258,13 +259,19 @@ object RelayGroupModerationCommands {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]` → 9000. */
|
/**
|
||||||
|
* `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator] [--buzz-role ROLE]` → 9000.
|
||||||
|
*
|
||||||
|
* Buzz ignores the roles in the `p` tag and reads only a top-level `role` tag
|
||||||
|
* (owner|admin|member|guest|bot); `--buzz-role` sets it. Without it a Buzz relay makes no
|
||||||
|
* role change: an existing member keeps their role and a newcomer joins as `member`.
|
||||||
|
*/
|
||||||
suspend fun putUser(
|
suspend fun putUser(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
): Int {
|
): Int {
|
||||||
val args = Args(rest)
|
val args = Args(rest)
|
||||||
val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]"
|
val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator] [--buzz-role owner|admin|member|guest|bot]"
|
||||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||||
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||||
@@ -275,12 +282,14 @@ object RelayGroupModerationCommands {
|
|||||||
?.split(',')
|
?.split(',')
|
||||||
?.map { it.trim() }
|
?.map { it.trim() }
|
||||||
?.filter { it.isNotEmpty() } ?: emptyList()
|
?.filter { it.isNotEmpty() } ?: emptyList()
|
||||||
|
val buzzRole = args.flag("buzz-role")?.trim()?.lowercase()
|
||||||
|
if (buzzRole != null && buzzRole !in BUZZ_ROLES) return Output.error("bad_args", usage)
|
||||||
args.rejectUnknown()
|
args.rejectUnknown()
|
||||||
|
|
||||||
Context.open(dataDir).use { ctx ->
|
Context.open(dataDir).use { ctx ->
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
val pubkey = ctx.requireUserHex(user)
|
val pubkey = ctx.requireUserHex(user)
|
||||||
val signed = ctx.signer.sign(GroupPutUserEvent.build(groupId, listOf(pubkey to roles)))
|
val signed = ctx.signer.sign(GroupPutUserEvent.build(groupId, listOf(pubkey to roles), buzzRole = buzzRole))
|
||||||
val ack = ctx.publish(signed, setOf(relay))
|
val ack = ctx.publish(signed, setOf(relay))
|
||||||
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
|
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
|
||||||
Output.emit(
|
Output.emit(
|
||||||
@@ -290,6 +299,7 @@ object RelayGroupModerationCommands {
|
|||||||
"relay" to relay.url,
|
"relay" to relay.url,
|
||||||
"pubkey" to pubkey,
|
"pubkey" to pubkey,
|
||||||
"roles" to roles,
|
"roles" to roles,
|
||||||
|
"buzz_role" to buzzRole,
|
||||||
"published" to ack.values.any { it.accepted },
|
"published" to ack.values.any { it.accepted },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -186,8 +186,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
|
|||||||
import com.vitorpamplona.amethyst.commons.util.logTime
|
import com.vitorpamplona.amethyst.commons.util.logTime
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
|
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||||
@@ -2239,7 +2238,7 @@ class Account(
|
|||||||
// [com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply]).
|
// [com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply]).
|
||||||
//
|
//
|
||||||
// Attached media rides as URLs appended to the content.
|
// Attached media rides as URLs appended to the content.
|
||||||
val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id
|
val root = rootEvent.tags.buzzThreadRootForReplyTo(rootEvent.id)
|
||||||
signer.sign(
|
signer.sign(
|
||||||
ChatEvent.build(finalText) {
|
ChatEvent.build(finalText) {
|
||||||
hTag(group.groupId.id)
|
hTag(group.groupId.id)
|
||||||
|
|||||||
+65
-8
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.model
|
package com.vitorpamplona.amethyst.commons.model
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
|
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
@@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembe
|
|||||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent
|
||||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||||
@@ -41,13 +43,17 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
||||||
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
|
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_BOT
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_GUEST
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_OWNER
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
@@ -75,9 +81,11 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin
|
|||||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin
|
||||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-29 relay-group and Buzz-workspace orchestration for an [Account]:
|
* NIP-29 relay-group and Buzz-workspace orchestration for an [Account]:
|
||||||
@@ -158,6 +166,21 @@ class AccountRelayGroupActions(
|
|||||||
?.substringBefore('"')
|
?.substringBefore('"')
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks [channel]'s Buzz relay which huddle sessions in it are live right now and records them
|
||||||
|
* in [BuzzHuddleLivenessState]. The relay synthesizes kind-48104 answers only for a REQ whose
|
||||||
|
* every filter is exactly `kinds:[48104]` with an authorized `#h`, so this is its own REQ, never
|
||||||
|
* part of the channel's timeline subscription. Buzz's desktop repeats it every 10 seconds.
|
||||||
|
*/
|
||||||
|
suspend fun refreshBuzzHuddleLiveness(channel: RelayGroupChannel) {
|
||||||
|
val filter = HuddleLivenessEvent.filter(listOf(channel.groupId.id))
|
||||||
|
val now = TimeUtils.now()
|
||||||
|
account.client.fetchAll(channel.groupId.relayUrl, filter, idleTimeoutMs = 8_000).forEach { event ->
|
||||||
|
if (event !is HuddleLivenessEvent || event.channelId() != channel.groupId.id) return@forEach
|
||||||
|
event.sessionId()?.let { BuzzHuddleLivenessState.record(channel.groupId.id, it, now) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
|
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
|
||||||
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
|
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
|
||||||
val template = DmHideEvent.build(channel.groupId.id)
|
val template = DmHideEvent.build(channel.groupId.id)
|
||||||
@@ -231,6 +254,7 @@ class AccountRelayGroupActions(
|
|||||||
* the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker
|
* the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker
|
||||||
* offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write.
|
* offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
suspend fun publishBuzzWorkflowDef(
|
suspend fun publishBuzzWorkflowDef(
|
||||||
relay: NormalizedRelayUrl,
|
relay: NormalizedRelayUrl,
|
||||||
channelId: String,
|
channelId: String,
|
||||||
@@ -238,7 +262,8 @@ class AccountRelayGroupActions(
|
|||||||
yaml: String,
|
yaml: String,
|
||||||
): String? {
|
): String? {
|
||||||
if (!account.isWriteable()) return null
|
if (!account.isWriteable()) return null
|
||||||
val workflowId = RandomInstance.randomChars(16)
|
// The relay parses the `d` tag as a UUID and refuses anything else.
|
||||||
|
val workflowId = Uuid.random().toString()
|
||||||
val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
|
val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
|
||||||
account.cache.justConsumeMyOwnEvent(signed)
|
account.cache.justConsumeMyOwnEvent(signed)
|
||||||
account.client.publish(signed, setOf(relay))
|
account.client.publish(signed, setOf(relay))
|
||||||
@@ -493,8 +518,28 @@ class AccountRelayGroupActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add [pubkey] to the group (or change its roles) with a kind 9000 put-user
|
* Add [pubkey] to the group as a plain member with a kind 9000 put-user event, without
|
||||||
* event (moderator only). Pass an empty [roles] list for a plain member.
|
* touching the role of someone who is already in it.
|
||||||
|
*
|
||||||
|
* On Buzz this sends **no** `role` tag: the relay reads a missing role as "no role change",
|
||||||
|
* keeping an existing member's role and defaulting only a newcomer to `member`. Sending
|
||||||
|
* `role=member` instead would demote an admin who happened to be re-added, or be refused
|
||||||
|
* outright (`only owners/admins may change an active member's role`) when the actor is not
|
||||||
|
* elevated. Ground truth: `handle_put_user` in `buzz-relay/src/handlers/side_effects.rs` and
|
||||||
|
* `decide_put_user` in `channel_authz.rs`.
|
||||||
|
*/
|
||||||
|
suspend fun addRelayGroupUser(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
pubkey: HexKey,
|
||||||
|
) {
|
||||||
|
val template = GroupPutUserEvent.build(channel.groupId.id, listOf(pubkey to emptyList()))
|
||||||
|
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set [pubkey]'s roles in the group with a kind 9000 put-user event (moderator only). An
|
||||||
|
* empty [roles] list makes them a plain member - on Buzz that is an explicit demotion to
|
||||||
|
* `member`. To add someone without changing a role, use [addRelayGroupUser].
|
||||||
*/
|
*/
|
||||||
suspend fun putRelayGroupUser(
|
suspend fun putRelayGroupUser(
|
||||||
channel: RelayGroupChannel,
|
channel: RelayGroupChannel,
|
||||||
@@ -506,10 +551,7 @@ class AccountRelayGroupActions(
|
|||||||
// the whole put-user, which is why an unmapped role must become `member` rather than travel.
|
// the whole put-user, which is why an unmapped role must become `member` rather than travel.
|
||||||
val buzzRole =
|
val buzzRole =
|
||||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||||
when {
|
buzzRoleFor(roles)
|
||||||
roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN
|
|
||||||
else -> BUZZ_ROLE_MEMBER
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -606,3 +648,18 @@ class AccountRelayGroupActions(
|
|||||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps NIP-29 role names onto Buzz's closed role set (owner/admin/member/guest/bot), picking the
|
||||||
|
* most privileged one present. Anything else, including NIP-29's `moderator`, becomes `member`.
|
||||||
|
*/
|
||||||
|
internal fun buzzRoleFor(roles: List<String>): String {
|
||||||
|
fun has(role: String) = roles.any { it.equals(role, true) }
|
||||||
|
return when {
|
||||||
|
has(BUZZ_ROLE_OWNER) -> BUZZ_ROLE_OWNER
|
||||||
|
has(RelayGroupMembership.ROLE_ADMIN) || has(BUZZ_ROLE_ADMIN) -> BUZZ_ROLE_ADMIN
|
||||||
|
has(BUZZ_ROLE_BOT) -> BUZZ_ROLE_BOT
|
||||||
|
has(BUZZ_ROLE_GUEST) -> BUZZ_ROLE_GUEST
|
||||||
|
else -> BUZZ_ROLE_MEMBER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+68
-8
@@ -20,11 +20,15 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.model
|
package com.vitorpamplona.amethyst.commons.model
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
|
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
|
||||||
|
import com.vitorpamplona.quartz.utils.Hex
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -33,9 +37,11 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
* in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the
|
* in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the
|
||||||
* note rather than the cache.
|
* note rather than the cache.
|
||||||
*
|
*
|
||||||
* All four kinds apply ONLY edits authored by the edited note's own author: the send side gates
|
* Every kind applies ONLY edits the message's author was entitled to make, checked here rather
|
||||||
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord,
|
* than trusted to the relay (Buzz) or an encrypted-plane peer (Concord, Marmot), so a
|
||||||
* Marmot) is trusted to enforce that, so a foreign-authored edit never rewrites your message.
|
* foreign-authored edit never rewrites your message. For Concord, Marmot and kind-1010 that is the
|
||||||
|
* note's own author. Buzz also lets the owner of an agent edit the agent's messages, and credits a
|
||||||
|
* relay-signed message to the member it names ([buzzEffectiveAuthor], [buzzEditorsOf]).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,11 +58,65 @@ fun Note.textNoteModifications(): List<Note> {
|
|||||||
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The kind-40003 Buzz edit overlaying this message, or null — author-only, newest by created_at. */
|
/**
|
||||||
fun Note.latestBuzzEdit(): Note? {
|
* Who a Buzz message counts as written by. Normally its signer; but when the channel's relay signed
|
||||||
val authorHex = author?.pubkeyHex ?: return null
|
* it on a member's behalf (a workflow posting, a legacy relay-attributed message) the member named in
|
||||||
return edits
|
* its `actor` tag, else its first `p` tag. The relay's key is the author of the channel's
|
||||||
.filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex }
|
* relay-signed 39000 metadata, so a user-signed message can never claim someone else this way.
|
||||||
|
* Mirrors `effective_message_author` in Buzz's `buzz-relay/src/handlers/ingest.rs` and
|
||||||
|
* `resolveEventAuthorPubkey` in its desktop client (which also wants the `h` tag before reading `p`).
|
||||||
|
*/
|
||||||
|
fun Note.buzzEffectiveAuthor(): HexKey? {
|
||||||
|
val event = event ?: return author?.pubkeyHex
|
||||||
|
val relayKey = inGatherers?.firstNotNullOfOrNull { (it as? RelayGroupChannel)?.event?.pubKey }
|
||||||
|
if (relayKey != null && event.pubKey == relayKey) {
|
||||||
|
event.tags.firstOrNull { it.size > 1 && it[0] == "actor" && Hex.isHex64(it[1]) }?.let { return it[1] }
|
||||||
|
if (event.tags.any { it.size > 1 && it[0] == "h" }) {
|
||||||
|
event.tags.firstOrNull { it.size > 1 && it[0] == "p" && Hex.isHex64(it[1]) }?.let { return it[1] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return event.pubKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [signer] may edit this Buzz message: its [buzzEffectiveAuthor], or the owner that author
|
||||||
|
* declares through a verified NIP-OA `auth` tag on its kind-0 profile (Buzz lets the owning human
|
||||||
|
* edit its agent's messages). Mirrors `validate_edit_ownership` on the relay and
|
||||||
|
* `isAuthorizedMessageEdit` in Buzz's desktop client.
|
||||||
|
*/
|
||||||
|
fun Note.isBuzzEditableBy(
|
||||||
|
signer: HexKey,
|
||||||
|
users: (HexKey) -> User? = LocalCache::getUserIfExists,
|
||||||
|
): Boolean {
|
||||||
|
val author = buzzEffectiveAuthor() ?: return false
|
||||||
|
return signer == author || signer == buzzOwnerOf(author, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buzzOwnerOf(
|
||||||
|
author: HexKey,
|
||||||
|
users: (HexKey) -> User?,
|
||||||
|
): HexKey? =
|
||||||
|
users(author)
|
||||||
|
?.metadataOrNull()
|
||||||
|
?.flow
|
||||||
|
?.value
|
||||||
|
?.nipOaOwner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind-40003 Buzz edit overlaying this message, or null: the newest ([createdAt], then id) of
|
||||||
|
* the edits its author or the author's agent owner made ([isBuzzEditableBy]).
|
||||||
|
*/
|
||||||
|
fun Note.latestBuzzEdit(users: (HexKey) -> User? = LocalCache::getUserIfExists): Note? {
|
||||||
|
val candidates = edits.filter { it.event is StreamMessageEditEvent }
|
||||||
|
if (candidates.isEmpty()) return null
|
||||||
|
val author = buzzEffectiveAuthor() ?: return null
|
||||||
|
// Resolve the owner only when some edit is not the author's own; it costs a signature check.
|
||||||
|
val owner by lazy { buzzOwnerOf(author, users) }
|
||||||
|
return candidates
|
||||||
|
.filter {
|
||||||
|
val signer = it.author?.pubkeyHex
|
||||||
|
signer != null && (signer == author || signer == owner)
|
||||||
|
}
|
||||||
// idHex tie-break so a same-second pair resolves identically on every client.
|
// idHex tie-break so a same-second pair resolves identically on every client.
|
||||||
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
|
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
+105
@@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.Account
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.tags.ExpectedRevisionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes a Buzz canvas (kind 40100) edit with the writer discipline Buzz's own clients use
|
||||||
|
* (`set_canvas` in `desktop/src-tauri/src/commands/canvas.rs`):
|
||||||
|
*
|
||||||
|
* - the write asserts the head the editor was opened on with `["expected-revision", <head id>]`,
|
||||||
|
* or `none` when there was no canvas yet, so the relay refuses it (`conflict: …`) instead of
|
||||||
|
* silently overwriting someone else's newer revision;
|
||||||
|
* - it is stamped `created_at = max(now, head.created_at + 1)` ([CanvasEvent.writeCreatedAt]) so
|
||||||
|
* it sorts strictly ahead of that head, and is refused outright when the head sits too far in
|
||||||
|
* the future to ratchet past.
|
||||||
|
*
|
||||||
|
* Unlike the fire-and-forget broadcaster, this waits for the relay's OK: a rejected write must
|
||||||
|
* not be folded into [BuzzWorkspaceStates] as the new head, and the conflict has to reach the UI.
|
||||||
|
*/
|
||||||
|
object BuzzCanvasWriter {
|
||||||
|
sealed interface Outcome {
|
||||||
|
/** The relay stored the revision; it is now the local head too. */
|
||||||
|
data class Saved(
|
||||||
|
val event: CanvasEvent,
|
||||||
|
) : Outcome
|
||||||
|
|
||||||
|
/** The canvas changed since the editor loaded it (the relay's `conflict:` rejection). */
|
||||||
|
data class Conflict(
|
||||||
|
val message: String,
|
||||||
|
) : Outcome
|
||||||
|
|
||||||
|
/** The loaded head is timestamped too far in the future to write after; nothing was sent. */
|
||||||
|
data object HeadTooFarInFuture : Outcome
|
||||||
|
|
||||||
|
/** Any other rejection, or no answer at all. */
|
||||||
|
data class Failed(
|
||||||
|
val message: String,
|
||||||
|
) : Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs and publishes [markdown] as the new canvas of [channelId] on [relay], asserting the
|
||||||
|
* head the editor started from ([headId] / [headCreatedAt], both null when there was none).
|
||||||
|
*/
|
||||||
|
suspend fun save(
|
||||||
|
account: Account,
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
channelId: String,
|
||||||
|
markdown: String,
|
||||||
|
headId: HexKey?,
|
||||||
|
headCreatedAt: Long?,
|
||||||
|
now: Long = TimeUtils.now(),
|
||||||
|
): Outcome {
|
||||||
|
val createdAt = CanvasEvent.writeCreatedAt(headCreatedAt, now) ?: return Outcome.HeadTooFarInFuture
|
||||||
|
val template = CanvasEvent.build(channelId, markdown, createdAt, expectedRevision = headId ?: ExpectedRevisionTag.NONE)
|
||||||
|
val signed = account.signer.sign(template)
|
||||||
|
|
||||||
|
var results = account.client.publishAndCollectResults(signed, setOf(relay))
|
||||||
|
// A cold socket answers the first write with `auth-required` while our AUTH lands; the
|
||||||
|
// relay does not replay it, so send once more on the now-authenticated connection.
|
||||||
|
if (results.values.none { it.accepted } && results.values.any { it.message.contains("auth-required", ignoreCase = true) }) {
|
||||||
|
results = account.client.publishAndCollectResults(signed, setOf(relay))
|
||||||
|
}
|
||||||
|
|
||||||
|
val outcome = classify(signed, results.values)
|
||||||
|
if (outcome is Outcome.Saved) account.cache.justConsumeMyOwnEvent(signed)
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the relay's OK answers to an [Outcome]: any acceptance wins, then a `conflict:`, then the first reason. */
|
||||||
|
fun classify(
|
||||||
|
event: CanvasEvent,
|
||||||
|
results: Collection<PublishResult>,
|
||||||
|
): Outcome {
|
||||||
|
if (results.any { it.accepted }) return Outcome.Saved(event)
|
||||||
|
results.firstOrNull { CanvasEvent.isConflict(it.message) }?.let { return Outcome.Conflict(it.message) }
|
||||||
|
return Outcome.Failed(results.firstOrNull()?.message ?: PublishResult.NO_RESPONSE)
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.model.buzz
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -50,10 +51,12 @@ class BuzzHeldAttestations(
|
|||||||
val flow: StateFlow<OwnerAttestation?> = held
|
val flow: StateFlow<OwnerAttestation?> = held
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `auth` tag to attach to this account's NIP-42 AUTH event, or null when no verified
|
* The `auth` tag to attach to an AUTH event signed at [authCreatedAt], or null when no verified
|
||||||
* attestation is held.
|
* attestation is held or its `created_at<` / `created_at>` bounds exclude that moment. Buzz
|
||||||
|
* checks those bounds against the AUTH event's timestamp and refuses membership outside them,
|
||||||
|
* so an expired credential stays held (the UI can say it expired) but is not presented.
|
||||||
*/
|
*/
|
||||||
fun authTag(): Array<String>? = held.value?.toTag()
|
fun authTag(authCreatedAt: Long = TimeUtils.now()): Array<String>? = held.value?.takeIf { it.isValidAt(authCreatedAt) }?.toTag()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores [attestation] as authorizing this account, if it verifies for [agentPubKey]. Returns
|
* Stores [attestation] as authorizing this account, if it verifies for [agentPubKey]. Returns
|
||||||
|
|||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
|
import kotlinx.collections.immutable.PersistentMap
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Buzz huddle sessions are live right now, per channel, fed by the relay-synthesized
|
||||||
|
* kind-48104 liveness events (`HuddleLivenessEvent`). The relay builds those on demand from its
|
||||||
|
* live huddle table, answers only for sessions that are live, and never stores them, so a
|
||||||
|
* session's liveness is "seen in a recent answer": Buzz's desktop re-asks every 10 seconds and
|
||||||
|
* treats a session that stops appearing as ended. [LIVE_WINDOW_SECS] leaves room for two missed
|
||||||
|
* polls before a huddle drops off.
|
||||||
|
*
|
||||||
|
* Shape: `channelId -> sessionId -> last seen (seconds)`. Lock-guarded because `LocalCache`
|
||||||
|
* consume runs on several relay reader threads. Process-wide singleton like [BuzzPresenceState].
|
||||||
|
*/
|
||||||
|
object BuzzHuddleLivenessState {
|
||||||
|
const val LIVE_WINDOW_SECS = 30L
|
||||||
|
|
||||||
|
private val lock = KmpLock()
|
||||||
|
private val mutableSeen = MutableStateFlow<PersistentMap<String, PersistentMap<String, Long>>>(persistentMapOf())
|
||||||
|
|
||||||
|
/** `channelId -> sessionId -> last seen`; collect it and filter with [liveSessions]. */
|
||||||
|
val flow: StateFlow<Map<String, Map<String, Long>>> = mutableSeen
|
||||||
|
|
||||||
|
/** Records that the relay reported [sessionId] in [channelId] live as of [seenAtSecs]. */
|
||||||
|
fun record(
|
||||||
|
channelId: String,
|
||||||
|
sessionId: String,
|
||||||
|
seenAtSecs: Long,
|
||||||
|
) = lock.withLock {
|
||||||
|
val sessions = mutableSeen.value[channelId] ?: persistentMapOf()
|
||||||
|
val prev = sessions[sessionId]
|
||||||
|
if (prev != null && seenAtSecs <= prev) return@withLock
|
||||||
|
mutableSeen.value = mutableSeen.value.putting(channelId, sessions.putting(sessionId, seenAtSecs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sessions in [channelId] reported live within [LIVE_WINDOW_SECS] of [nowSecs]. */
|
||||||
|
fun liveSessions(
|
||||||
|
channelId: String,
|
||||||
|
nowSecs: Long,
|
||||||
|
snapshot: Map<String, Map<String, Long>> = mutableSeen.value,
|
||||||
|
): Set<String> =
|
||||||
|
snapshot[channelId]
|
||||||
|
?.filterValues { nowSecs - it <= LIVE_WINDOW_SECS }
|
||||||
|
?.keys
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
/** Test-only: clears all state so unit tests don't leak into each other. */
|
||||||
|
fun clearForTesting() =
|
||||||
|
lock.withLock {
|
||||||
|
mutableSeen.value = persistentMapOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
|
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.NamingIdentity
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.ResolvedIdentityName
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buzz's contextual identity names for one relay group: each member's label, lengthened only when
|
||||||
|
* someone else in the channel shares the name ("Alice’s Honey", "Honey (agent)", "Honey · 7xk2"). See
|
||||||
|
* [IdentityNamePolicy] for the rule. Names are compared against the channel's members, so a label only
|
||||||
|
* grows when a real collision exists there; a non-member author is resolved against the members plus
|
||||||
|
* itself.
|
||||||
|
*
|
||||||
|
* Each channel's result is cached until its roster changes or any profile or agent profile arrives
|
||||||
|
* ([invalidate], which bumps [version]); the UI can collect [version] to redraw.
|
||||||
|
*/
|
||||||
|
object BuzzIdentityNames {
|
||||||
|
private val mutableVersion = MutableStateFlow(0L)
|
||||||
|
|
||||||
|
/** Bumped whenever a name, agent profile or owner may have changed. */
|
||||||
|
val version: StateFlow<Long> = mutableVersion
|
||||||
|
|
||||||
|
/** A kind-0 or kind-10100 landed; names computed so far may be stale. */
|
||||||
|
fun invalidate() {
|
||||||
|
mutableVersion.value = mutableVersion.value + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Entry(
|
||||||
|
val members: Set<HexKey>,
|
||||||
|
val version: Long,
|
||||||
|
val viewer: HexKey,
|
||||||
|
val resolved: Map<HexKey, ResolvedIdentityName>,
|
||||||
|
val outside: MutableMap<HexKey, ResolvedIdentityName?> = HashMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val lock = KmpLock()
|
||||||
|
private val cache = HashMap<GroupId, Entry>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contextual label for [pubkey] in [channel] as [viewer] sees it, or null when there is no
|
||||||
|
* name to disambiguate (an invalid key). The label equals the plain name unless it collides.
|
||||||
|
*/
|
||||||
|
fun labelFor(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
pubkey: HexKey,
|
||||||
|
viewer: HexKey,
|
||||||
|
users: (HexKey) -> User? = LocalCache::getUserIfExists,
|
||||||
|
hasAgentProfile: (HexKey) -> Boolean = ::hasCachedAgentProfile,
|
||||||
|
): ResolvedIdentityName? {
|
||||||
|
val members = channel.allMemberKeys()
|
||||||
|
val version = mutableVersion.value
|
||||||
|
val entry =
|
||||||
|
lock.withLock {
|
||||||
|
cache[channel.groupId]?.takeIf { it.members === members && it.version == version && it.viewer == viewer }
|
||||||
|
} ?: Entry(members, version, viewer, resolve(members, viewer, users, hasAgentProfile)).also { lock.withLock { cache[channel.groupId] = it } }
|
||||||
|
|
||||||
|
val key = pubkey.lowercase()
|
||||||
|
if (key in entry.resolved) return entry.resolved[key]
|
||||||
|
return lock.withLock { entry.outside[key] }
|
||||||
|
?: resolve(members + key, viewer, users, hasAgentProfile)[key].also { found -> lock.withLock { entry.outside[key] = found } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The naming fact for [pubkey]: its profile name, and whether (and whose) agent it is. */
|
||||||
|
fun factFor(
|
||||||
|
pubkey: HexKey,
|
||||||
|
users: (HexKey) -> User?,
|
||||||
|
hasAgentProfile: (HexKey) -> Boolean = ::hasCachedAgentProfile,
|
||||||
|
): NamingIdentity {
|
||||||
|
val user = users(pubkey)
|
||||||
|
val info = user?.metadataOrNull()?.flow?.value
|
||||||
|
val owner = info?.nipOaOwner
|
||||||
|
return NamingIdentity(
|
||||||
|
pubkey = pubkey,
|
||||||
|
name = info?.info?.bestName()?.takeIf { IdentityNamePolicy.trim(it).isNotEmpty() } ?: user?.pubkeyDisplayHex() ?: pubkey.take(8),
|
||||||
|
isAgent = owner != null || hasAgentProfile(pubkey),
|
||||||
|
ownerPubkey = owner,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the cache holds a kind-10100 agent profile by [pubkey]. */
|
||||||
|
fun hasCachedAgentProfile(pubkey: HexKey): Boolean = LocalCache.getAddressableNoteIfExists(AgentProfileEvent.createAddress(pubkey))?.event is AgentProfileEvent
|
||||||
|
|
||||||
|
private fun resolve(
|
||||||
|
candidates: Set<HexKey>,
|
||||||
|
viewer: HexKey,
|
||||||
|
users: (HexKey) -> User?,
|
||||||
|
hasAgentProfile: (HexKey) -> Boolean,
|
||||||
|
): Map<HexKey, ResolvedIdentityName> {
|
||||||
|
val facts = candidates.filter { isKey(it) }.map { factFor(it, users, hasAgentProfile) }
|
||||||
|
// Owners outside the channel only lend their names ("Alice’s Honey"); they don't compete.
|
||||||
|
// Listed first so a member's own fact stays the preferred one.
|
||||||
|
val ownerFacts =
|
||||||
|
facts
|
||||||
|
.mapNotNull { it.ownerPubkey }
|
||||||
|
.filter { it !in candidates }
|
||||||
|
.distinct()
|
||||||
|
.mapNotNull { owner ->
|
||||||
|
users(owner)
|
||||||
|
?.metadataOrNull()
|
||||||
|
?.flow
|
||||||
|
?.value
|
||||||
|
?.info
|
||||||
|
?.bestName()
|
||||||
|
?.takeIf { IdentityNamePolicy.trim(it).isNotEmpty() }
|
||||||
|
?.let { NamingIdentity(owner, it) }
|
||||||
|
}
|
||||||
|
return IdentityNamePolicy.resolve(ownerFacts + facts, viewer = viewer.takeIf { isKey(it) }, candidates = facts.map { it.pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isKey(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||||
|
|
||||||
|
/** Test-only. */
|
||||||
|
fun clearForTesting() =
|
||||||
|
lock.withLock {
|
||||||
|
cache.clear()
|
||||||
|
mutableVersion.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.buzz
|
|||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -46,7 +47,10 @@ import kotlin.concurrent.Volatile
|
|||||||
class BuzzWorkspaceState {
|
class BuzzWorkspaceState {
|
||||||
private val lock = KmpLock()
|
private val lock = KmpLock()
|
||||||
|
|
||||||
/** The newest canvas (kind 40100) note for this channel, or null when none seen. */
|
/**
|
||||||
|
* The live canvas (kind 40100) note for this channel, or null when none seen — picked the way
|
||||||
|
* the relay reads its head: `created_at DESC, id ASC` (see [CanvasEvent.isNewerHead]).
|
||||||
|
*/
|
||||||
@Volatile
|
@Volatile
|
||||||
var canvasNote: Note? = null
|
var canvasNote: Note? = null
|
||||||
private set
|
private set
|
||||||
@@ -58,7 +62,10 @@ class BuzzWorkspaceState {
|
|||||||
|
|
||||||
fun updateCanvas(note: Note) =
|
fun updateCanvas(note: Note) =
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
|
val current = canvasNote
|
||||||
|
// A same-second tie goes to the smallest id, not the last arrival, so every client
|
||||||
|
// (and the relay's own CAS check) agrees on which revision is the head.
|
||||||
|
if (CanvasEvent.isNewerHead(note.createdAt() ?: 0L, note.idHex, current?.createdAt(), current?.idHex)) {
|
||||||
canvasNote = note
|
canvasNote = note
|
||||||
canvasVersion.value = canvasVersion.value + 1
|
canvasVersion.value = canvasVersion.value + 1
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-2
@@ -36,6 +36,8 @@ import com.vitorpamplona.amethyst.commons.model.UserContext
|
|||||||
import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents
|
import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||||
@@ -64,7 +66,10 @@ import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
||||||
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
|
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
|
||||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactRemovalEvent
|
||||||
import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent
|
import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadWindowBoundsEvent
|
||||||
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
|
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
|
||||||
@@ -77,6 +82,7 @@ import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
|
||||||
@@ -98,6 +104,7 @@ import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
|
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
|
||||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
|
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
|
||||||
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
|
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.ProjectEvent
|
||||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||||
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
|
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
|
||||||
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
|
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
|
||||||
@@ -119,6 +126,7 @@ import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
|
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
|
||||||
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
|
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
|
||||||
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
|
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.teamCatalog.TeamCatalogEvent
|
||||||
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
|
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
|
||||||
@@ -1098,6 +1106,7 @@ open class EventCache :
|
|||||||
val newUserMetadata = event.contactMetaData()
|
val newUserMetadata = event.contactMetaData()
|
||||||
if (newUserMetadata != null && (wasVerified || justVerify(event))) {
|
if (newUserMetadata != null && (wasVerified || justVerify(event))) {
|
||||||
user.updateUserInfo(newUserMetadata, event)
|
user.updateUserInfo(newUserMetadata, event)
|
||||||
|
BuzzIdentityNames.invalidate()
|
||||||
if (relay != null) {
|
if (relay != null) {
|
||||||
user.addRelayBeingUsed(relay, event.createdAt)
|
user.addRelayBeingUsed(relay, event.createdAt)
|
||||||
}
|
}
|
||||||
@@ -2329,7 +2338,7 @@ open class EventCache :
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes
|
* Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes
|
||||||
* what the composer sends (40002 vs kind 9) and how new channels on the relay are
|
* what the composer sends (Buzz's kind-9 shape vs NIP-29's) and how new channels on the relay are
|
||||||
* treated, so an unverifiable frame from a buggy/hostile relay must not flip it.
|
* treated, so an unverifiable frame from a buggy/hostile relay must not flip it.
|
||||||
* The note-has-event check is the same verification gate the attach path uses.
|
* The note-has-event check is the same verification gate the attach path uses.
|
||||||
*/
|
*/
|
||||||
@@ -3757,6 +3766,11 @@ open class EventCache :
|
|||||||
is AuditEntryEvent,
|
is AuditEntryEvent,
|
||||||
is ChannelSummaryEvent,
|
is ChannelSummaryEvent,
|
||||||
is PresenceSnapshotEvent,
|
is PresenceSnapshotEvent,
|
||||||
|
// NIP-AR artifact revisions and relay-signed removal markers: queryable state, never
|
||||||
|
// chat rows - the spec keeps them out of chat, reply and unread counts. The current
|
||||||
|
// head per `d` is derived with ArtifactHeadResolver (by `prev`, not by time).
|
||||||
|
is ArtifactEvent,
|
||||||
|
is ArtifactRemovalEvent,
|
||||||
-> consumeBuzzRegularEvent(event, relay, wasVerified)
|
-> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||||
|
|
||||||
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
|
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
|
||||||
@@ -3776,8 +3790,19 @@ open class EventCache :
|
|||||||
BuzzPresenceState.record(event.subjectPubKey(), event.status(), event.createdAt)
|
BuzzPresenceState.record(event.subjectPubKey(), event.status(), event.createdAt)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
// An agent profile makes its author an agent, which can change how names in a
|
||||||
|
// channel are told apart.
|
||||||
|
is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified).also { BuzzIdentityNames.invalidate() }
|
||||||
is ObserverFrameEvent -> false
|
is ObserverFrameEvent -> false
|
||||||
is HuddleReactionEvent -> false
|
is HuddleReactionEvent -> false
|
||||||
|
// Relay-synthesized "this huddle session is live" (48104): only produced on demand
|
||||||
|
// for a dedicated REQ and never stored, so record it and keep no note.
|
||||||
|
is HuddleLivenessEvent -> {
|
||||||
|
val channel = event.channelId()
|
||||||
|
val session = event.sessionId()
|
||||||
|
if (channel != null && session != null) BuzzHuddleLivenessState.record(channel, session, TimeUtils.now())
|
||||||
|
false
|
||||||
|
}
|
||||||
// Pairing (24134) is deliberately dialect-neutral: it flows during device
|
// Pairing (24134) is deliberately dialect-neutral: it flows during device
|
||||||
// pairing before any workspace relationship is established.
|
// pairing before any workspace relationship is established.
|
||||||
is PairingEvent -> false
|
is PairingEvent -> false
|
||||||
@@ -3900,13 +3925,15 @@ open class EventCache :
|
|||||||
// Buzz addressable/replaceable state.
|
// Buzz addressable/replaceable state.
|
||||||
is PersonaEvent,
|
is PersonaEvent,
|
||||||
is TeamEvent,
|
is TeamEvent,
|
||||||
|
is TeamCatalogEvent,
|
||||||
|
is ProjectEvent,
|
||||||
is ManagedAgentEvent,
|
is ManagedAgentEvent,
|
||||||
is AgentProfileEvent,
|
|
||||||
is EngramEvent,
|
is EngramEvent,
|
||||||
is WorkflowDefEvent,
|
is WorkflowDefEvent,
|
||||||
is EventReminderEvent,
|
is EventReminderEvent,
|
||||||
is PushLeaseEvent,
|
is PushLeaseEvent,
|
||||||
is WindowBoundsEvent,
|
is WindowBoundsEvent,
|
||||||
|
is ThreadWindowBoundsEvent,
|
||||||
is ArchivedIdentitiesListEvent,
|
is ArchivedIdentitiesListEvent,
|
||||||
is RelayDiscoveryEvent,
|
is RelayDiscoveryEvent,
|
||||||
is RelayMonitorEvent,
|
is RelayMonitorEvent,
|
||||||
|
|||||||
+9
-7
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.model.chats
|
package com.vitorpamplona.amethyst.commons.model.chats
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.isBroadcast
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
@@ -31,10 +32,11 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
|||||||
* its parent message, NOT as a flat sibling in the main timeline. Three dialects express the same idea:
|
* its parent message, NOT as a flat sibling in the main timeline. Three dialects express the same idea:
|
||||||
*
|
*
|
||||||
* - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent].
|
* - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent].
|
||||||
* - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag. This
|
* - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag and
|
||||||
* is what every live Buzz client writes — `_buildReplyTags` in its Flutter client emits
|
* NOT flagged `broadcast`. This is what every live Buzz client (and Amethyst) writes —
|
||||||
* `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` +
|
* `build_message` emits `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` +
|
||||||
* `["e", parent, "", "reply"]` for a nested one, and all three of their clients send chat as kind 9.
|
* `["e", parent, "", "reply"]` for a nested one, plus `["broadcast", "1"]` when the reply should
|
||||||
|
* also show in the channel.
|
||||||
* - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT
|
* - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT
|
||||||
* flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it
|
* flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it
|
||||||
* "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the
|
* "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the
|
||||||
@@ -49,8 +51,8 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
|||||||
* as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves
|
* as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves
|
||||||
* that dialect untouched.
|
* that dialect untouched.
|
||||||
*
|
*
|
||||||
* A `broadcast=1` reply is an inline timeline sibling ("also send to channel"), matching block/buzz's
|
* A `broadcast=1` reply is an inline timeline sibling ("also send to channel") on either kind, matching
|
||||||
* `isThreadReply`. Kind 9 has no broadcast tag, so a marked kind-9 is always thread-only.
|
* block/buzz's `isBroadcastReply`, which the relay reads the same way whatever the kind.
|
||||||
*
|
*
|
||||||
* The timeline filter drops these (they belong in the minichat), the minichat count counts them, and
|
* The timeline filter drops these (they belong in the minichat), the minichat count counts them, and
|
||||||
* the minichat feed shows them — so all three agree on one definition.
|
* the minichat feed shows them — so all three agree on one definition.
|
||||||
@@ -58,7 +60,7 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
|||||||
fun isMinichatReply(event: Event?): Boolean =
|
fun isMinichatReply(event: Event?): Boolean =
|
||||||
when (event) {
|
when (event) {
|
||||||
is CommentEvent -> true
|
is CommentEvent -> true
|
||||||
is ChatEvent -> event.tags.buzzThreadReply() != null
|
is ChatEvent -> !event.tags.isBroadcast() && event.tags.buzzThreadReply() != null
|
||||||
is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null
|
is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.model.nip01Core
|
|||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||||
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||||
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
|
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
|
||||||
@@ -37,7 +39,17 @@ class UserInfo(
|
|||||||
val tags: ImmutableListOfLists<String>,
|
val tags: ImmutableListOfLists<String>,
|
||||||
val identities: List<IdentityClaimTag>,
|
val identities: List<IdentityClaimTag>,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
)
|
val pubKey: HexKey? = null,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* The owner this profile declares through a valid NIP-OA `auth` tag, making the user an agent
|
||||||
|
* of that owner (who may then edit the agent's Buzz messages), or null. Verified once per
|
||||||
|
* profile version.
|
||||||
|
*/
|
||||||
|
val nipOaOwner: HexKey? by lazy {
|
||||||
|
pubKey?.let { OwnerAttestation.verifiedOwnerOf(it, MetadataEvent.KIND, createdAt, tags.lists) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class UserMetadataCache {
|
class UserMetadataCache {
|
||||||
@@ -53,6 +65,7 @@ class UserMetadataCache {
|
|||||||
tags = metaEvent.tags.toImmutableListOfLists(),
|
tags = metaEvent.tags.toImmutableListOfLists(),
|
||||||
identities = metaEvent.identityClaims(),
|
identities = metaEvent.identityClaims(),
|
||||||
createdAt = metaEvent.createdAt,
|
createdAt = metaEvent.createdAt,
|
||||||
|
pubKey = metaEvent.pubKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
|
|||||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
@@ -139,6 +140,8 @@ class RelayGroupChannel(
|
|||||||
|
|
||||||
private fun recomputeAllMembers() {
|
private fun recomputeAllMembers() {
|
||||||
allMembers = if (admins.isEmpty()) members else members + admins.mapTo(HashSet()) { it.pubKey }
|
allMembers = if (admins.isEmpty()) members else members + admins.mapTo(HashSet()) { it.pubKey }
|
||||||
|
// Who is in the channel decides which names collide there.
|
||||||
|
if (BuzzRelayDialect.isBuzz(groupId.relayUrl)) BuzzIdentityNames.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -316,6 +319,9 @@ class RelayGroupChannel(
|
|||||||
/** Number of known members (admins are members too). */
|
/** Number of known members (admins are members too). */
|
||||||
fun memberCount(): Int = allMembers.size
|
fun memberCount(): Int = allMembers.size
|
||||||
|
|
||||||
|
/** Every known member and admin. The set is replaced, never mutated, when the roster changes. */
|
||||||
|
fun allMemberKeys(): Set<HexKey> = allMembers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The subset of this group's members/admins that [follows] contains — "people you follow who
|
* The subset of this group's members/admins that [follows] contains — "people you follow who
|
||||||
* are in here". Reads the cached [allMembers] set, so it's cheap to call per recomposition.
|
* are in here". Reads the cached [allMembers] set, so it's cheap to call per recomposition.
|
||||||
|
|||||||
+11
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses
|
|||||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.UserDependencies
|
import com.vitorpamplona.amethyst.commons.model.UserDependencies
|
||||||
|
import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
@@ -42,7 +43,7 @@ class UserStatusCache : UserDependencies {
|
|||||||
|
|
||||||
fun addStatus(note: AddressableNote) {
|
fun addStatus(note: AddressableNote) {
|
||||||
// if it's already there, quick exit
|
// if it's already there, quick exit
|
||||||
if (statuses.value.contains(note) || note.event?.content.isNullOrBlank()) return
|
if (statuses.value.contains(note) || note.isEmptyStatus()) return
|
||||||
|
|
||||||
// don't add expired statuses
|
// don't add expired statuses
|
||||||
if (note.event?.isExpired() == true) return
|
if (note.event?.isExpired() == true) return
|
||||||
@@ -52,6 +53,15 @@ class UserStatusCache : UserDependencies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing to show: no event, or blank text with no plain status emoji. An emoji-only status
|
||||||
|
* (Buzz writes `["emoji", "🌴"]` with blank content) is still a status.
|
||||||
|
*/
|
||||||
|
private fun Note.isEmptyStatus(): Boolean {
|
||||||
|
val event = event ?: return true
|
||||||
|
return if (event is UserStatusEvent) event.isCleared() else event.content.isBlank()
|
||||||
|
}
|
||||||
|
|
||||||
fun removeStatus(deleteNote: AddressableNote) {
|
fun removeStatus(deleteNote: AddressableNote) {
|
||||||
// if it's not already there, quick exit
|
// if it's not already there, quick exit
|
||||||
if (!statuses.value.contains(deleteNote)) return
|
if (!statuses.value.contains(deleteNote)) return
|
||||||
|
|||||||
+5
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.forEachChunk
|
|||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||||
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
|
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
|
||||||
|
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
|
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
|
||||||
@@ -57,6 +58,10 @@ val UserMetadataForKeyKinds =
|
|||||||
// kind:17375 — that's the user's private wallet (NIP-44 encrypted
|
// kind:17375 — that's the user's private wallet (NIP-44 encrypted
|
||||||
// to them); only the kind:10019 nutzap announcement is public.
|
// to them); only the kind:10019 nutzap announcement is public.
|
||||||
NutzapInfoEvent.KIND,
|
NutzapInfoEvent.KIND,
|
||||||
|
// Buzz agent profile (kind:10100): marks the author as an agent and carries its metadata
|
||||||
|
// and channel-add policy. Co-loaded with kind:0 so an agent is recognizable the moment it
|
||||||
|
// renders. (Its owner comes from the NIP-OA `auth` tag on the kind:0 itself.)
|
||||||
|
AgentProfileEvent.KIND,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun filterUserMetadataForKey(
|
fun filterUserMetadataForKey(
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class BuzzRoleForTest {
|
||||||
|
@Test
|
||||||
|
fun picksTheMostPrivilegedBuzzRole() {
|
||||||
|
assertEquals("owner", buzzRoleFor(listOf("admin", "owner")))
|
||||||
|
assertEquals("admin", buzzRoleFor(listOf("admin")))
|
||||||
|
assertEquals("admin", buzzRoleFor(listOf("Admin")))
|
||||||
|
assertEquals("bot", buzzRoleFor(listOf("bot")))
|
||||||
|
assertEquals("guest", buzzRoleFor(listOf("guest")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownOrEmptyRolesBecomeMember() {
|
||||||
|
// Buzz has no moderator; an unparseable role would fail the whole put-user.
|
||||||
|
assertEquals("member", buzzRoleFor(listOf("moderator")))
|
||||||
|
assertEquals("member", buzzRoleFor(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.buzz.amTurnMetrics.TokenCounts
|
|||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class AgentFleetAggregatorTest {
|
class AgentFleetAggregatorTest {
|
||||||
@@ -196,4 +197,67 @@ class AgentFleetAggregatorTest {
|
|||||||
assertEquals(50L, metrics.totals.totalTokens)
|
assertEquals(50L, metrics.totals.totalTokens)
|
||||||
assertFalse(metrics.hasUnreliableEstimates)
|
assertFalse(metrics.hasUnreliableEstimates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cache(
|
||||||
|
read: Long? = null,
|
||||||
|
write: Long? = null,
|
||||||
|
) = TokenCounts(inputTokens = 100, cacheReadTokens = read, cacheWriteTokens = write)
|
||||||
|
|
||||||
|
/** NIP-AM: an omitted cache component is unknown — the total must not become a fabricated 0. */
|
||||||
|
@Test
|
||||||
|
fun unreportedCacheStaysUnknown() {
|
||||||
|
val metrics =
|
||||||
|
AgentFleetAggregator.aggregate(
|
||||||
|
listOf(
|
||||||
|
turn(agentA, "s1", 1, turn = counts(input = 100)),
|
||||||
|
turn(agentA, "s1", 2, turn = counts(input = 100)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertNull(metrics.totals.cacheReadTokens)
|
||||||
|
assertNull(metrics.totals.cacheWriteTokens)
|
||||||
|
assertEquals(200L, metrics.totals.inputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun explicitZeroCacheIsKnownZero() {
|
||||||
|
val metrics = AgentFleetAggregator.aggregate(listOf(turn(agentA, "s1", 1, turn = cache(read = 0, write = 0))))
|
||||||
|
assertEquals(0L, metrics.totals.cacheReadTokens)
|
||||||
|
assertEquals(0L, metrics.totals.cacheWriteTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cacheUsesMaxCumulativeThenReportedDeltas() {
|
||||||
|
val metrics =
|
||||||
|
AgentFleetAggregator.aggregate(
|
||||||
|
listOf(
|
||||||
|
// Session s1 reports cumulatively; s2 only per turn, and only for reads.
|
||||||
|
turn(agentA, "s1", 1, cumulative = cache(read = 40, write = 5)),
|
||||||
|
turn(agentA, "s1", 2, cumulative = cache(read = 90, write = 5)),
|
||||||
|
turn(agentA, "s2", 3, turn = cache(read = 7)),
|
||||||
|
turn(agentA, "s2", 4, turn = cache(read = 3)),
|
||||||
|
// Another agent that never reports cache at all.
|
||||||
|
turn(agentB, "s3", 5, turn = counts(input = 1)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val a = metrics.agents.single { it.agentPubKey == agentA }
|
||||||
|
assertEquals(100L, a.totals.cacheReadTokens)
|
||||||
|
assertEquals(5L, a.totals.cacheWriteTokens)
|
||||||
|
|
||||||
|
val b = metrics.agents.single { it.agentPubKey == agentB }
|
||||||
|
assertNull(b.totals.cacheReadTokens)
|
||||||
|
|
||||||
|
// Unknown sessions add nothing to the fleet total, but don't erase the known ones.
|
||||||
|
assertEquals(100L, metrics.totals.cacheReadTokens)
|
||||||
|
assertEquals(5L, metrics.totals.cacheWriteTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tokenTotalsAddUnknownAsIdentity() {
|
||||||
|
val known = TokenTotals(cacheReadTokens = 3)
|
||||||
|
val unknown = TokenTotals()
|
||||||
|
assertEquals(3L, (known + unknown).cacheReadTokens)
|
||||||
|
assertEquals(3L, (unknown + known).cacheReadTokens)
|
||||||
|
assertNull((unknown + unknown).cacheReadTokens)
|
||||||
|
assertEquals(6L, (known + known).cacheReadTokens)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
|
||||||
|
class BuzzCanvasHeadTest {
|
||||||
|
private val channel = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
|
||||||
|
|
||||||
|
private fun canvas(
|
||||||
|
id: String,
|
||||||
|
createdAt: Long,
|
||||||
|
): Note =
|
||||||
|
Note(id).apply {
|
||||||
|
event = CanvasEvent(id, "a".repeat(64), createdAt, arrayOf(arrayOf("h", channel)), "rev $id", "sig")
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() = BuzzWorkspaceStates.clearForTesting()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newerCreatedAtWins() {
|
||||||
|
val state = BuzzWorkspaceState()
|
||||||
|
val old = canvas("1".repeat(64), 100)
|
||||||
|
val new = canvas("2".repeat(64), 101)
|
||||||
|
state.updateCanvas(new)
|
||||||
|
state.updateCanvas(old)
|
||||||
|
assertSame(new, state.canvasNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The relay reads the head as `created_at DESC, id ASC`: a same-second tie goes to the smaller id. */
|
||||||
|
@Test
|
||||||
|
fun sameSecondTieGoesToTheSmallestIdNotTheLastArrival() {
|
||||||
|
val small = canvas("1".repeat(64), 100)
|
||||||
|
val big = canvas("f".repeat(64), 100)
|
||||||
|
|
||||||
|
val bigFirst = BuzzWorkspaceState()
|
||||||
|
bigFirst.updateCanvas(big)
|
||||||
|
bigFirst.updateCanvas(small)
|
||||||
|
assertSame(small, bigFirst.canvasNote)
|
||||||
|
|
||||||
|
val smallFirst = BuzzWorkspaceState()
|
||||||
|
smallFirst.updateCanvas(small)
|
||||||
|
smallFirst.updateCanvas(big)
|
||||||
|
assertSame(small, smallFirst.canvasNote)
|
||||||
|
assertEquals(1, smallFirst.canvasUpdates.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconsumingTheHeadDoesNotBump() {
|
||||||
|
val state = BuzzWorkspaceState()
|
||||||
|
val head = canvas("1".repeat(64), 100)
|
||||||
|
state.updateCanvas(head)
|
||||||
|
state.updateCanvas(head)
|
||||||
|
assertEquals(1, state.canvasUpdates.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writerClassifiesRelayAnswers() {
|
||||||
|
val event = CanvasEvent("1".repeat(64), "a".repeat(64), 100, arrayOf(arrayOf("h", channel)), "x", "sig")
|
||||||
|
|
||||||
|
assertIs<BuzzCanvasWriter.Outcome.Saved>(BuzzCanvasWriter.classify(event, listOf(PublishResult(true, ""))))
|
||||||
|
|
||||||
|
val conflict = BuzzCanvasWriter.classify(event, listOf(PublishResult(false, "conflict: canvas changed since it was loaded")))
|
||||||
|
assertIs<BuzzCanvasWriter.Outcome.Conflict>(conflict)
|
||||||
|
assertEquals("conflict: canvas changed since it was loaded", conflict.message)
|
||||||
|
|
||||||
|
val invalid = BuzzCanvasWriter.classify(event, listOf(PublishResult(false, "invalid: canvas created_at too far in the future")))
|
||||||
|
assertIs<BuzzCanvasWriter.Outcome.Failed>(invalid)
|
||||||
|
|
||||||
|
val silent = BuzzCanvasWriter.classify(event, emptyList())
|
||||||
|
assertIs<BuzzCanvasWriter.Outcome.Failed>(silent)
|
||||||
|
assertEquals(PublishResult.NO_RESPONSE, silent.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||||
|
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class BuzzHuddleLivenessStateTest {
|
||||||
|
@AfterTest
|
||||||
|
fun reset() = BuzzHuddleLivenessState.clearForTesting()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aReportedSessionIsLiveUntilItStopsBeingReported() {
|
||||||
|
BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 1_000)
|
||||||
|
|
||||||
|
assertEquals(setOf("s1"), BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_000))
|
||||||
|
assertEquals(setOf("s1"), BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_000 + BuzzHuddleLivenessState.LIVE_WINDOW_SECS))
|
||||||
|
// Two missed 10s polls later it is gone: the relay only answers for live sessions.
|
||||||
|
assertTrue(BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_001 + BuzzHuddleLivenessState.LIVE_WINDOW_SECS).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionsAreScopedToTheirChannelAndAnOlderReportIsIgnored() {
|
||||||
|
BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 1_000)
|
||||||
|
BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 900)
|
||||||
|
|
||||||
|
assertTrue(BuzzHuddleLivenessState.liveSessions("other", nowSecs = 1_000).isEmpty())
|
||||||
|
assertEquals(1_000L, BuzzHuddleLivenessState.flow.value["chan"]?.get("s1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.UserContext
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||||
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class BuzzIdentityNamesTest {
|
||||||
|
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
|
||||||
|
private val context = UserContext { AddressableNote(it) }
|
||||||
|
private val users = HashMap<HexKey, User>()
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun reset() = BuzzIdentityNames.clearForTesting()
|
||||||
|
|
||||||
|
private fun user(
|
||||||
|
pubkey: HexKey,
|
||||||
|
name: String,
|
||||||
|
vararg tags: Array<String>,
|
||||||
|
) = User(pubkey, context).also {
|
||||||
|
it.metadata().flow.value = UserInfo(UserMetadata().apply { this.name = name }, arrayOf(*tags).toImmutableListOfLists(), emptyList(), 1, pubkey)
|
||||||
|
users[pubkey] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channelWith(vararg members: HexKey) =
|
||||||
|
RelayGroupChannel(GroupId("6a39da2f-33c0-44f6-a050-c4da0138644a", relay)).also { channel ->
|
||||||
|
channel.updateMembers(GroupMembersEvent("00", "ff".repeat(32), 10, members.map { arrayOf("p", it) }.toTypedArray(), "", "00"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun label(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
pubkey: HexKey,
|
||||||
|
viewer: HexKey,
|
||||||
|
) = BuzzIdentityNames.labelFor(channel, pubkey, viewer, users = { users[it] }, hasAgentProfile = { false })?.name
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAgentSharingAMembersNameIsNamedAfterItsOwner() {
|
||||||
|
val alice = KeyPair()
|
||||||
|
val aliceKey = alice.pubKey.toHexKey()
|
||||||
|
val me = "1".repeat(64)
|
||||||
|
val human = "c".repeat(64)
|
||||||
|
val agent = "a".repeat(64)
|
||||||
|
user(me, "Me")
|
||||||
|
user(aliceKey, "Alice")
|
||||||
|
user(human, "Honey")
|
||||||
|
user(agent, "Honey", OwnerAttestation.sign(agent, "", alice.privKey!!).toTag())
|
||||||
|
|
||||||
|
val channel = channelWith(me, human, agent)
|
||||||
|
// The person keeps the plain name; the agent borrows its owner's.
|
||||||
|
assertEquals("Honey", label(channel, human, me))
|
||||||
|
assertEquals("Alice’s Honey", label(channel, agent, me))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun namesOnlyGrowOnARealCollision() {
|
||||||
|
val me = "1".repeat(64)
|
||||||
|
val a = "a".repeat(64)
|
||||||
|
val b = "b".repeat(64)
|
||||||
|
user(me, "Me")
|
||||||
|
user(a, "Sam")
|
||||||
|
user(b, "Sam")
|
||||||
|
|
||||||
|
// Alone in the channel, Sam is just Sam.
|
||||||
|
assertEquals("Sam", label(channelWith(me, a), a, me))
|
||||||
|
// With a namesake, both get a key suffix.
|
||||||
|
val both = channelWith(me, a, b)
|
||||||
|
assertTrue(label(both, a, me)!!.startsWith("Sam · "))
|
||||||
|
assertTrue(label(both, a, me) != label(both, b, me))
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -96,4 +96,10 @@ class MinichatReplyTest {
|
|||||||
fun `root marker alone is not a reply`() {
|
fun `root marker alone is not a reply`() {
|
||||||
assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root"))))
|
assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Also send to channel": Buzz flags the reply `broadcast`, on kind 9 exactly as on 40002. */
|
||||||
|
@Test
|
||||||
|
fun `buzz broadcast reply stays in the channel timeline`() {
|
||||||
|
assertFalse(isMinichatReply(chat(arrayOf("e", parentId, "", "reply"), arrayOf("broadcast", "1"))))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.nip01Core
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||||
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class UserInfoAgentOwnerTest {
|
||||||
|
private val owner = KeyPair()
|
||||||
|
private val agent = KeyPair().pubKey.toHexKey()
|
||||||
|
|
||||||
|
private fun info(
|
||||||
|
vararg tags: Array<String>,
|
||||||
|
pubKey: String? = agent,
|
||||||
|
) = UserInfo(UserMetadata(), arrayOf(*tags).toImmutableListOfLists(), emptyList(), createdAt = 1_000, pubKey = pubKey)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aProfileWithAValidAttestationNamesItsOwner() {
|
||||||
|
val tag = OwnerAttestation.sign(agent, "", owner.privKey!!).toTag()
|
||||||
|
assertEquals(owner.pubKey.toHexKey(), info(tag).nipOaOwner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noAttestationOrOneForAnotherKeyMeansNoOwner() {
|
||||||
|
assertNull(info().nipOaOwner)
|
||||||
|
val forSomeoneElse = OwnerAttestation.sign(KeyPair().pubKey.toHexKey(), "", owner.privKey!!).toTag()
|
||||||
|
assertNull(info(forSomeoneElse).nipOaOwner)
|
||||||
|
// Without the profile's own pubkey there is nothing to verify against.
|
||||||
|
assertNull(info(OwnerAttestation.sign(agent, "", owner.privKey!!).toTag(), pubKey = null).nipOaOwner)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.relayClient.user.watchers
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class UserMetadataForKeyKindsTest {
|
||||||
|
@Test
|
||||||
|
fun agentProfilesLoadWithEveryUserProfile() {
|
||||||
|
assertTrue(MetadataEvent.KIND in UserMetadataForKeyKinds)
|
||||||
|
assertTrue(AgentProfileEvent.KIND in UserMetadataForKeyKinds)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-6
@@ -45,26 +45,47 @@ import okhttp3.coroutines.executeAsync
|
|||||||
* `crates/buzz-relay/src/api/invites.rs`.
|
* `crates/buzz-relay/src/api/invites.rs`.
|
||||||
*/
|
*/
|
||||||
object BuzzInviteMinter {
|
object BuzzInviteMinter {
|
||||||
/** A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs). */
|
/** Shortest lifetime the relay accepts (`MIN_INVITE_TTL_SECS` in `buzz-core/src/invite.rs`). */
|
||||||
|
const val MIN_TTL_SECS = 60L
|
||||||
|
|
||||||
|
/** Longest lifetime the relay accepts: 30 days (`MAX_INVITE_TTL_SECS`). */
|
||||||
|
const val MAX_TTL_SECS = 30L * 24 * 60 * 60
|
||||||
|
|
||||||
|
/** Largest `max_uses` the relay accepts (`MAX_INVITE_USES`, the database constraint). */
|
||||||
|
const val MAX_USES = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs).
|
||||||
|
* [maxUses] / [usesRemaining] are null for an unlimited invite (the default).
|
||||||
|
*/
|
||||||
data class MintedInvite(
|
data class MintedInvite(
|
||||||
val code: String,
|
val code: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
val expiresAt: Long,
|
val expiresAt: Long,
|
||||||
|
val maxUses: Int? = null,
|
||||||
|
val usesRemaining: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (relay clamps to [60, 30d];
|
* POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (default 72 h) and an
|
||||||
* default 72 h). [httpAuth] signs the NIP-98 event over the exact URL + body; [okHttpClient]
|
* optional [maxUses] cap (default unlimited). The relay does **not** clamp: a [ttlSecs] outside
|
||||||
* supplies the transport (use a trusted-relay-posture client so a Cloudflare-fronted relay is
|
* [[MIN_TTL_SECS], [MAX_TTL_SECS]] or a [maxUses] outside [1, [MAX_USES]] is refused with a 400,
|
||||||
* reached over clearnet). Throws [IllegalStateException] with the relay's error slug on failure.
|
* so both are checked here first. [httpAuth] signs the NIP-98 event over the exact URL + body;
|
||||||
|
* [okHttpClient] supplies the transport (use a trusted-relay-posture client so a
|
||||||
|
* Cloudflare-fronted relay is reached over clearnet). Throws [IllegalStateException] with the
|
||||||
|
* relay's error message on failure.
|
||||||
*/
|
*/
|
||||||
suspend fun mint(
|
suspend fun mint(
|
||||||
relay: NormalizedRelayUrl,
|
relay: NormalizedRelayUrl,
|
||||||
ttlSecs: Long?,
|
ttlSecs: Long?,
|
||||||
okHttpClient: (String) -> OkHttpClient,
|
okHttpClient: (String) -> OkHttpClient,
|
||||||
httpAuth: suspend (url: String, method: String, body: ByteArray?) -> HTTPAuthorizationEvent,
|
httpAuth: suspend (url: String, method: String, body: ByteArray?) -> HTTPAuthorizationEvent,
|
||||||
|
maxUses: Int? = null,
|
||||||
): MintedInvite =
|
): MintedInvite =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
require(ttlSecs == null || ttlSecs in MIN_TTL_SECS..MAX_TTL_SECS) { "ttl_secs must be between $MIN_TTL_SECS and $MAX_TTL_SECS" }
|
||||||
|
require(maxUses == null || maxUses in 1..MAX_USES) { "max_uses must be between 1 and $MAX_USES" }
|
||||||
|
|
||||||
// wss://host[/..] -> https://host ; ws://host -> http://host. The endpoint is host-root.
|
// wss://host[/..] -> https://host ; ws://host -> http://host. The endpoint is host-root.
|
||||||
val wsUrl = relay.url
|
val wsUrl = relay.url
|
||||||
val scheme = if (wsUrl.startsWith("wss", ignoreCase = true)) "https" else "http"
|
val scheme = if (wsUrl.startsWith("wss", ignoreCase = true)) "https" else "http"
|
||||||
@@ -75,7 +96,7 @@ object BuzzInviteMinter {
|
|||||||
val url = httpUrl.toString()
|
val url = httpUrl.toString()
|
||||||
|
|
||||||
// Exact bytes the NIP-98 payload hash is computed over — must equal what we send.
|
// Exact bytes the NIP-98 payload hash is computed over — must equal what we send.
|
||||||
val bodyStr = ttlSecs?.let { "{\"ttl_secs\":$it}" } ?: "{}"
|
val bodyStr = requestBody(ttlSecs, maxUses)
|
||||||
val bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
|
val bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
val auth = httpAuth(url, "POST", bodyBytes)
|
val auth = httpAuth(url, "POST", bodyBytes)
|
||||||
@@ -101,9 +122,21 @@ object BuzzInviteMinter {
|
|||||||
code = tree?.get("code")?.stringOrNull().orEmpty(),
|
code = tree?.get("code")?.stringOrNull().orEmpty(),
|
||||||
url = tree?.get("url")?.stringOrNull().orEmpty(),
|
url = tree?.get("url")?.stringOrNull().orEmpty(),
|
||||||
expiresAt = tree?.get("expires_at")?.longOrNull() ?: 0L,
|
expiresAt = tree?.get("expires_at")?.longOrNull() ?: 0L,
|
||||||
|
maxUses = tree?.get("max_uses")?.longOrNull()?.toInt(),
|
||||||
|
usesRemaining = tree?.get("uses_remaining")?.longOrNull()?.toInt(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The exact JSON body of a mint request; omitted fields take the relay's defaults (72 h, unlimited). */
|
||||||
|
fun requestBody(
|
||||||
|
ttlSecs: Long?,
|
||||||
|
maxUses: Int?,
|
||||||
|
): String =
|
||||||
|
buildList {
|
||||||
|
ttlSecs?.let { add("\"ttl_secs\":$it") }
|
||||||
|
maxUses?.let { add("\"max_uses\":$it") }
|
||||||
|
}.joinToString(",", prefix = "{", postfix = "}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonElement.stringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
private fun JsonElement.stringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.actions
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class BuzzInviteMinterTest {
|
||||||
|
@Test
|
||||||
|
fun requestBodyOmitsUnsetFields() {
|
||||||
|
assertEquals("{}", BuzzInviteMinter.requestBody(null, null))
|
||||||
|
assertEquals("""{"ttl_secs":3600}""", BuzzInviteMinter.requestBody(3600, null))
|
||||||
|
assertEquals("""{"max_uses":5}""", BuzzInviteMinter.requestBody(null, 5))
|
||||||
|
assertEquals("""{"ttl_secs":60,"max_uses":10000}""", BuzzInviteMinter.requestBody(60, 10_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -593,6 +593,10 @@
|
|||||||
<string name="concord_invite_revoke_title">Unieważnić ten link?</string>
|
<string name="concord_invite_revoke_title">Unieważnić ten link?</string>
|
||||||
<string name="concord_invite_revoke_explainer">Każdy nadal posiadający ten link nie będzie mógł już dołączyć. Osoby, które już się do niej dołączyły, pozostają w społeczności. Tego nie można cofnąć.</string>
|
<string name="concord_invite_revoke_explainer">Każdy nadal posiadający ten link nie będzie mógł już dołączyć. Osoby, które już się do niej dołączyły, pozostają w społeczności. Tego nie można cofnąć.</string>
|
||||||
<string name="concord_invite_revoke_confirm">Unieważnij</string>
|
<string name="concord_invite_revoke_confirm">Unieważnij</string>
|
||||||
|
<string name="concord_invite_revoke_privatize_warning">To jest ostatni link zapraszający do tej społeczności. Jego cofnięcie spowoduje, że społeczność stanie się prywatna, a jej klucze zostaną odświeżone, dzięki czemu dostęp zachowają wyłącznie obecni członkowie.</string>
|
||||||
|
<string name="concord_invite_revoked_privatized">Link został unieważniony. Społeczność ma teraz status prywatny, a jej klucze zostały odnowione.</string>
|
||||||
|
<string name="concord_invite_revoked_privatize_pending">Link został unieważniony. Społeczność ma teraz status prywatny, ale jej klucze nie zostały odświeżone: administrator posiadający uprawnienia do blokowania członków musi je odświeżyć.</string>
|
||||||
|
<string name="concord_mode_private">Prywatne</string>
|
||||||
<string name="concord_invite_preview_unknown_name">Nazwa społeczności zostanie ujawniona dopiero po dołączeniu</string>
|
<string name="concord_invite_preview_unknown_name">Nazwa społeczności zostanie ujawniona dopiero po dołączeniu</string>
|
||||||
<string name="concord_invite_preview_explainer">Dołączenie do transmiterów tego zaproszenia, publikuje komunikaty o dołączeniu podpisane przez twoje konto i dodaje społeczność do listy. Nic nie zostanie wysłane, dopóki nie klikniesz Dołącz.</string>
|
<string name="concord_invite_preview_explainer">Dołączenie do transmiterów tego zaproszenia, publikuje komunikaty o dołączeniu podpisane przez twoje konto i dodaje społeczność do listy. Nic nie zostanie wysłane, dopóki nie klikniesz Dołącz.</string>
|
||||||
<string name="concord_invite_preview_relays">Transmiter tego zaproszenia skontaktuje się: %1$s</string>
|
<string name="concord_invite_preview_relays">Transmiter tego zaproszenia skontaktuje się: %1$s</string>
|
||||||
@@ -601,8 +605,19 @@
|
|||||||
<string name="concord_open_channel">Otwórz kanał</string>
|
<string name="concord_open_channel">Otwórz kanał</string>
|
||||||
<string name="concord_edit_banner_hint">Dodaj baner</string>
|
<string name="concord_edit_banner_hint">Dodaj baner</string>
|
||||||
<string name="concord_edit_relays_desc">Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności.</string>
|
<string name="concord_edit_relays_desc">Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności.</string>
|
||||||
|
<string name="concord_timer_title">Znikające wiadomości</string>
|
||||||
|
<string name="concord_timer_desc">Po upływie tego czasu nowe wiadomości we wszystkich kanałach są usuwane z urządzeń użytkowników oraz z transmiterów. Zmiana tego ustawienia nie ma wpływu na wiadomości, które zostały już wysłane. Każdy, kto ma dostęp do wiadomości, nadal może ją skopiować.</string>
|
||||||
|
<string name="concord_timer_off">Wył.</string>
|
||||||
|
<string name="concord_timer_notice_set">%1$s ustawił znikające wiadomości do %2$s</string>
|
||||||
|
<string name="concord_timer_notice_off">%1$s wyłączył(a) funkcję znikających wiadomości</string>
|
||||||
|
<string name="concord_timer_active">Wiadomości znikną za %1$s</string>
|
||||||
<string name="concord_dissolved_read_only">Ta społeczność została rozwiązana i jest tylko do odczytu. Nadal możesz przeczytać jej historię, ale nie można opublikować żadnych nowych wiadomości.</string>
|
<string name="concord_dissolved_read_only">Ta społeczność została rozwiązana i jest tylko do odczytu. Nadal możesz przeczytać jej historię, ale nie można opublikować żadnych nowych wiadomości.</string>
|
||||||
<string name="concord_private_channel_no_key">To jest kanał prywatny, a ty nie posiadasz klucza dostępu do niego, więc nie możesz czytać ani publikować w nim postów.</string>
|
<string name="concord_private_channel_no_key">To jest kanał prywatny, a ty nie posiadasz klucza dostępu do niego, więc nie możesz czytać ani publikować w nim postów.</string>
|
||||||
|
<string name="concord_pinned_title">Przypięte wiadomości</string>
|
||||||
|
<string name="concord_pinned_empty">Brak przypiętych wiadomości na tym kanale.</string>
|
||||||
|
<string name="concord_pinned_unavailable">Piny tego kanału są zabezpieczone kluczem, którego nie posiadasz, więc nie można ich tutaj wyświetlić, a przypinanie jest wstrzymane do czasu, aż będzie można je odczytać.</string>
|
||||||
|
<string name="concord_pin_failed_title">Pineski</string>
|
||||||
|
<string name="concord_pin_failed_unavailable">Przypięta lista jest zabezpieczona kluczem, którego nie posiadasz. Jej zmiana w tej chwili spowodowałaby usunięcie przypiętych elementów, których nie widzisz.</string>
|
||||||
<string name="concord_create_name">Nazwa</string>
|
<string name="concord_create_name">Nazwa</string>
|
||||||
<string name="concord_create_about">Informacje (opcjonalne)</string>
|
<string name="concord_create_about">Informacje (opcjonalne)</string>
|
||||||
<string name="concord_ban_user">Banuj</string>
|
<string name="concord_ban_user">Banuj</string>
|
||||||
@@ -3653,6 +3668,17 @@
|
|||||||
<string name="concord_create_relays">Transmitery</string>
|
<string name="concord_create_relays">Transmitery</string>
|
||||||
<string name="concord_create_relays_desc">Transmitery, które przechowują zaszyfrowane wiadomości tej społeczności. Pozostaw puste, aby użyć własnych.</string>
|
<string name="concord_create_relays_desc">Transmitery, które przechowują zaszyfrowane wiadomości tej społeczności. Pozostaw puste, aby użyć własnych.</string>
|
||||||
<string name="concord_create_title">Nowy kanał Concord</string>
|
<string name="concord_create_title">Nowy kanał Concord</string>
|
||||||
|
<string name="concord_direct_invite_accept">Zaakceptuj</string>
|
||||||
|
<string name="concord_direct_invite_action">Zaproszenie od npub…</string>
|
||||||
|
<string name="concord_direct_invite_decline">Odrzuć</string>
|
||||||
|
<string name="concord_direct_invite_explainer">Zaproszenie jest szyfrowane dla tej osoby i dostarczane do jej transmiterów skrzynki odbiorczej. Zawiera wyłącznie kanały prywatne przypisane jej na podstawie pełnionych ról i nie można go cofnąć po wysłaniu.</string>
|
||||||
|
<string name="concord_direct_invite_expired">To zaproszenie wygasło</string>
|
||||||
|
<string name="concord_direct_invite_from">Zaproszono przez %1$s</string>
|
||||||
|
<string name="concord_direct_invite_hint">Nazwa, npub lub NIP-05</string>
|
||||||
|
<string name="concord_direct_invite_send">Wyślij zaproszenie do %1$s</string>
|
||||||
|
<string name="concord_direct_invite_sent">Zaproszenie zostało wysłane</string>
|
||||||
|
<string name="concord_direct_invite_title">Zaproś kogoś bezpośrednio</string>
|
||||||
|
<string name="concord_direct_invites_title">Zaproszenia do społeczności</string>
|
||||||
<string name="concord_edit_title">Edytuj społeczność</string>
|
<string name="concord_edit_title">Edytuj społeczność</string>
|
||||||
<string name="concord_editing_banner">Edytowanie wiadomości</string>
|
<string name="concord_editing_banner">Edytowanie wiadomości</string>
|
||||||
<string name="concord_home_title">Kanały Concord</string>
|
<string name="concord_home_title">Kanały Concord</string>
|
||||||
@@ -3662,6 +3688,7 @@
|
|||||||
<string name="concord_invite_failed_expired">Ten link do zaproszenia wygasł i nie może być już używany. Poproś o nowy link.</string>
|
<string name="concord_invite_failed_expired">Ten link do zaproszenia wygasł i nie może być już używany. Poproś o nowy link.</string>
|
||||||
<string name="concord_invite_failed_incompatible">Nie można otworzyć tego linku do zaproszenia. Być może jest on nieaktualny, został już zastąpiony nowszym lub został utworzony w nowszej wersji aplikacji. Poproś o nowy link do zaproszenia.</string>
|
<string name="concord_invite_failed_incompatible">Nie można otworzyć tego linku do zaproszenia. Być może jest on nieaktualny, został już zastąpiony nowszym lub został utworzony w nowszej wersji aplikacji. Poproś o nowy link do zaproszenia.</string>
|
||||||
<string name="concord_invite_failed_invalid">Ten link do zaproszenia jest nieprawidłowy lub nie można go otworzyć przy użyciu tego konta.</string>
|
<string name="concord_invite_failed_invalid">Ten link do zaproszenia jest nieprawidłowy lub nie można go otworzyć przy użyciu tego konta.</string>
|
||||||
|
<string name="concord_invite_failed_not_saved">Zaproszenie działa, ale twoja lista społeczności nie mogła zostać zaktualizowana, więc nie dołączyłeś do niej. Spróbuj ponownie za chwilę.</string>
|
||||||
<string name="concord_invite_failed_revoked">Ten link do zaproszenia został unieważniony i nie można już z niego korzystać. Poproś o nowy.</string>
|
<string name="concord_invite_failed_revoked">Ten link do zaproszenia został unieważniony i nie można już z niego korzystać. Poproś o nowy.</string>
|
||||||
<string name="concord_invite_links_action">Linki z zaproszeniem</string>
|
<string name="concord_invite_links_action">Linki z zaproszeniem</string>
|
||||||
<string name="concord_invite_links_title">Linki z zaproszeniem</string>
|
<string name="concord_invite_links_title">Linki z zaproszeniem</string>
|
||||||
@@ -3680,6 +3707,8 @@
|
|||||||
<item quantity="other">%1$d członkowie</item>
|
<item quantity="other">%1$d członkowie</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="concord_members_ban">Banuj</string>
|
<string name="concord_members_ban">Banuj</string>
|
||||||
|
<string name="concord_members_ban_title">Zbanować użytkownika?</string>
|
||||||
|
<string name="concord_members_ban_message">Nie mogą już publikować postów, a ich wiadomości są ukryte przed wszystkimi. Inne aplikacje mogą usunąć tę społeczność ze swojej listy. Nadal mogą czytać nowe wiadomości, dopóki nie usuniesz ich również ze społeczności. Możesz później cofnąć im blokadę.</string>
|
||||||
<string name="concord_members_make_admin">Stwórz administratora</string>
|
<string name="concord_members_make_admin">Stwórz administratora</string>
|
||||||
<string name="concord_members_remove_admin">Usuń administratora</string>
|
<string name="concord_members_remove_admin">Usuń administratora</string>
|
||||||
<string name="concord_members_roles_failed">Nie udało się zaktualizować ról tego członka.</string>
|
<string name="concord_members_roles_failed">Nie udało się zaktualizować ról tego członka.</string>
|
||||||
|
|||||||
@@ -2363,6 +2363,11 @@
|
|||||||
<string name="buzz_canvas_edit">Edit canvas</string>
|
<string name="buzz_canvas_edit">Edit canvas</string>
|
||||||
<string name="buzz_canvas_save">Save canvas</string>
|
<string name="buzz_canvas_save">Save canvas</string>
|
||||||
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
|
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
|
||||||
|
<string name="buzz_canvas_conflict">Someone else changed this canvas since you started editing. Copy your changes, reopen the canvas and apply them again.</string>
|
||||||
|
<string name="buzz_canvas_head_in_future">The current canvas is timestamped too far in the future to be edited safely.</string>
|
||||||
|
<string name="buzz_canvas_save_failed">Couldn't save the canvas: %1$s</string>
|
||||||
|
<string name="buzz_system_admin_kick">%1$s was removed by an administrator</string>
|
||||||
|
<string name="buzz_system_channel_auto_archived">This channel was archived automatically</string>
|
||||||
<string name="buzz_editing_banner">Editing message</string>
|
<string name="buzz_editing_banner">Editing message</string>
|
||||||
<string name="message_edited">(edited)</string>
|
<string name="message_edited">(edited)</string>
|
||||||
<string name="buzz_typing_one">%1$s is typing…</string>
|
<string name="buzz_typing_one">%1$s is typing…</string>
|
||||||
@@ -3158,6 +3163,12 @@
|
|||||||
<string name="buzz_attest_hold_title">Hold an attestation</string>
|
<string name="buzz_attest_hold_title">Hold an attestation</string>
|
||||||
<string name="buzz_attest_holding">Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.</string>
|
<string name="buzz_attest_holding">Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.</string>
|
||||||
<string name="buzz_attest_grants_prefix">Grants: %1$s</string>
|
<string name="buzz_attest_grants_prefix">Grants: %1$s</string>
|
||||||
|
<string name="buzz_huddle_live">Huddle live</string>
|
||||||
|
<string name="buzz_agent_label">Agent</string>
|
||||||
|
<string name="buzz_agent_managed_by">managed by %1$s</string>
|
||||||
|
<string name="buzz_agent_managed_by_you">managed by you</string>
|
||||||
|
<string name="buzz_attest_expired">This attestation has expired. Buzz relays no longer accept it; ask the owner for a new one.</string>
|
||||||
|
<string name="buzz_attest_not_yet_valid">This attestation is not valid yet. Buzz relays will accept it once its start time passes.</string>
|
||||||
<string name="buzz_attest_grants_unrestricted">any kind, any time (unrestricted)</string>
|
<string name="buzz_attest_grants_unrestricted">any kind, any time (unrestricted)</string>
|
||||||
<string name="buzz_attest_remove">Remove</string>
|
<string name="buzz_attest_remove">Remove</string>
|
||||||
<string name="buzz_attest_hold_desc">Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.</string>
|
<string name="buzz_attest_hold_desc">Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.</string>
|
||||||
@@ -3187,6 +3198,12 @@
|
|||||||
<string name="buzz_persona_provider">Provider (optional)</string>
|
<string name="buzz_persona_provider">Provider (optional)</string>
|
||||||
<string name="buzz_persona_runtime">Runtime (optional)</string>
|
<string name="buzz_persona_runtime">Runtime (optional)</string>
|
||||||
<string name="buzz_persona_avatar">Avatar URL (optional)</string>
|
<string name="buzz_persona_avatar">Avatar URL (optional)</string>
|
||||||
|
<string name="buzz_persona_description">Description (optional)</string>
|
||||||
|
<string name="buzz_persona_description_help">Public, up to 280 characters.</string>
|
||||||
|
<string name="buzz_persona_shared">Share with the workspace</string>
|
||||||
|
<string name="buzz_persona_shared_help">Other members can see and use this persona. Off keeps it to your own devices.</string>
|
||||||
|
<string name="buzz_persona_thread_sessions">One conversation per thread</string>
|
||||||
|
<string name="buzz_persona_thread_sessions_help">The agent keeps a separate conversation for each thread instead of one per channel.</string>
|
||||||
<string name="buzz_persona_publishing">Publishing…</string>
|
<string name="buzz_persona_publishing">Publishing…</string>
|
||||||
<string name="buzz_persona_publish">Publish persona</string>
|
<string name="buzz_persona_publish">Publish persona</string>
|
||||||
<string name="profile_card_follows_you">Follows you</string>
|
<string name="profile_card_follows_you">Follows you</string>
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.buzz.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_agent_label
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_agent_managed_by
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_agent_managed_by_you
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.ResolvedIdentityName
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [author] has published a Buzz agent profile (kind 10100). The profile is requested with
|
||||||
|
* every user's kind 0, so this only watches the cache; it never asks a relay itself.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberHasBuzzAgentProfile(author: User): Boolean {
|
||||||
|
val note = remember(author) { LocalCache.getOrCreateAddressableNote(AgentProfileEvent.createAddress(author.pubkeyHex)) }
|
||||||
|
val hasProfile by produceState(note.event is AgentProfileEvent, note) {
|
||||||
|
note
|
||||||
|
.flow()
|
||||||
|
.metadata.stateFlow
|
||||||
|
.collect { value = note.event is AgentProfileEvent }
|
||||||
|
}
|
||||||
|
return hasProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Agent · managed by Alice" beside an agent's name, the way Buzz labels agents. [author] counts as
|
||||||
|
* an agent when it published a kind-10100 agent profile or its kind 0 names an [owner] through a
|
||||||
|
* verified NIP-OA `auth` tag; the owner part appears only for the latter. Draws nothing for a person.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BuzzAgentLabel(
|
||||||
|
author: User,
|
||||||
|
owner: HexKey?,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showOwner: Boolean = true,
|
||||||
|
onOwnerClick: ((HexKey) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val hasAgentProfile = rememberHasBuzzAgentProfile(author)
|
||||||
|
if (owner == null && !hasAgentProfile) return
|
||||||
|
|
||||||
|
val agent = stringRes(Res.string.buzz_agent_label)
|
||||||
|
val text =
|
||||||
|
when {
|
||||||
|
owner == null || !showOwner -> agent
|
||||||
|
owner == accountViewModel.userProfile().pubkeyHex -> "$agent · ${stringRes(Res.string.buzz_agent_managed_by_you)}"
|
||||||
|
else -> "$agent · ${stringRes(Res.string.buzz_agent_managed_by, observeUserNameByHex(owner, accountViewModel))}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = if (owner != null && showOwner && onOwnerClick != null) modifier.clickable { onOwnerClick(owner) } else modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [author]'s contextual name in the Buzz [channel] ([BuzzIdentityNames]): the plain name unless
|
||||||
|
* another member shares it, then "Alice’s Honey", "Honey (agent)" or "Honey · 7xk2". Recomputed when
|
||||||
|
* profiles, agent profiles or the roster change, at most every quarter second.
|
||||||
|
*/
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberBuzzContextualName(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
author: User,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
): ResolvedIdentityName? {
|
||||||
|
val versions = remember { BuzzIdentityNames.version.debounce(250) }
|
||||||
|
val version by versions.collectAsStateWithLifecycle(BuzzIdentityNames.version.value)
|
||||||
|
val viewer = accountViewModel.userProfile().pubkeyHex
|
||||||
|
return remember(channel, author, viewer, version) { BuzzIdentityNames.labelFor(channel, author.pubkeyHex, viewer) }
|
||||||
|
}
|
||||||
+15
-1
@@ -242,8 +242,22 @@ fun DisplayStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buzz sets a plain emoji with a 2-element `["emoji", "🎉"]` tag instead of inlining it in the
|
||||||
|
// text; lead with it the way Buzz's own client does. NIP-30 custom emoji are handled above.
|
||||||
|
val text =
|
||||||
|
remember(event) {
|
||||||
|
val emoji = event.statusEmoji()
|
||||||
|
if (emoji == null) {
|
||||||
|
event.content
|
||||||
|
} else if (event.content.isBlank()) {
|
||||||
|
emoji
|
||||||
|
} else {
|
||||||
|
"$emoji ${event.content}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisplayStatusInner(
|
DisplayStatusInner(
|
||||||
event.content,
|
text,
|
||||||
event.dTag(),
|
event.dTag(),
|
||||||
event.firstTaggedUrl()?.ifBlank { null },
|
event.firstTaggedUrl()?.ifBlank { null },
|
||||||
event.firstTaggedAddress(),
|
event.firstTaggedAddress(),
|
||||||
|
|||||||
+15
-1
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_attest_before_label
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_change_agent
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_change_agent
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_conditions_hint
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_conditions_hint
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_copy_tag
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_copy_tag
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_expired
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_form_desc
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_form_desc
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_generate
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_generate
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_grants_prefix
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_grants_prefix
|
||||||
@@ -85,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_desc
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_title
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_holding
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_holding
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_kind_label
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_kind_label
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_not_yet_valid
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_desc
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_desc
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_title
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_remove
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_remove
|
||||||
@@ -104,6 +106,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -122,7 +125,7 @@ private val KIND_OPTIONS =
|
|||||||
DropdownOption("9", "9 · Group chat message"),
|
DropdownOption("9", "9 · Group chat message"),
|
||||||
DropdownOption("1111", "1111 · Comment"),
|
DropdownOption("1111", "1111 · Comment"),
|
||||||
DropdownOption("30023", "30023 · Long-form article"),
|
DropdownOption("30023", "30023 · Long-form article"),
|
||||||
DropdownOption("40002", "40002 · Buzz minichat message"),
|
DropdownOption("40002", "40002 · Buzz stream message (legacy)"),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label }
|
private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label }
|
||||||
@@ -208,6 +211,17 @@ private fun HoldAttestationSection(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
// Buzz checks the time clauses against each AUTH, so a held credential outside them
|
||||||
|
// is silently worthless; say so rather than keep showing it as working.
|
||||||
|
val now = TimeUtils.now()
|
||||||
|
if (!mine.isValidAt(now)) {
|
||||||
|
val expired = mine.validUntil()?.let { it < now } == true
|
||||||
|
Text(
|
||||||
|
text = stringRes(if (expired) Res.string.buzz_attest_expired else Res.string.buzz_attest_not_yet_valid),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
OutlinedButton(onClick = { attestation.clear() }) {
|
OutlinedButton(onClick = { attestation.clear() }) {
|
||||||
Text(stringRes(Res.string.buzz_attest_remove))
|
Text(stringRes(Res.string.buzz_attest_remove))
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -231,8 +231,10 @@ private fun TokenBreakdown(totals: TokenTotals) {
|
|||||||
MetaRow("Input tokens", formatCount(totals.inputTokens))
|
MetaRow("Input tokens", formatCount(totals.inputTokens))
|
||||||
MetaRow("Output tokens", formatCount(totals.outputTokens))
|
MetaRow("Output tokens", formatCount(totals.outputTokens))
|
||||||
MetaRow("Total tokens", formatCount(totals.totalTokens))
|
MetaRow("Total tokens", formatCount(totals.totalTokens))
|
||||||
if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens))
|
// Null = no turn reported the component. NIP-AM forbids reading that as zero, so show a dash
|
||||||
if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens))
|
// rather than hide the row (which would look the same as a reported 0).
|
||||||
|
MetaRow("Cache read", totals.cacheReadTokens?.let(::formatCount) ?: "—")
|
||||||
|
MetaRow("Cache write", totals.cacheWriteTokens?.let(::formatCount) ?: "—")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+29
@@ -41,6 +41,8 @@ import com.vitorpamplona.amethyst.commons.buzz.ui.DropdownOption
|
|||||||
import com.vitorpamplona.amethyst.commons.buzz.ui.EditableSuggestDropdown
|
import com.vitorpamplona.amethyst.commons.buzz.ui.EditableSuggestDropdown
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_avatar
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_avatar
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_description
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_description_help
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_display_name
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_display_name
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_edit_title
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_edit_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_model
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_model
|
||||||
@@ -49,12 +51,17 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_persona_provider
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publish
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publish
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publishing
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publishing
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_runtime
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_runtime
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_shared
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_shared_help
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug_help
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug_help
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_system_prompt
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_system_prompt
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_thread_sessions
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_thread_sessions_help
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
|
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
|
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.SettingSwitchItem
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
|
|
||||||
@@ -161,6 +168,28 @@ fun AgentPersonaEditScreen(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.description,
|
||||||
|
onValueChange = viewModel::onDescriptionChange,
|
||||||
|
label = { Text(stringRes(Res.string.buzz_persona_description)) },
|
||||||
|
supportingText = { Text(stringRes(Res.string.buzz_persona_description_help)) },
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
SettingSwitchItem(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
checked = state.shared,
|
||||||
|
onCheckedChange = viewModel::onSharedChange,
|
||||||
|
title = Res.string.buzz_persona_shared,
|
||||||
|
description = Res.string.buzz_persona_shared_help,
|
||||||
|
)
|
||||||
|
SettingSwitchItem(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
checked = state.threadSessions,
|
||||||
|
onCheckedChange = viewModel::onThreadSessionsChange,
|
||||||
|
title = Res.string.buzz_persona_thread_sessions,
|
||||||
|
description = Res.string.buzz_persona_thread_sessions_help,
|
||||||
|
)
|
||||||
|
|
||||||
state.error?.let {
|
state.error?.let {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+29
-6
@@ -44,18 +44,20 @@ import kotlin.coroutines.cancellation.CancellationException
|
|||||||
* owner and addressed by `(owner, 30175, slug)`.
|
* owner and addressed by `(owner, 30175, slug)`.
|
||||||
*
|
*
|
||||||
* On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does
|
* On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does
|
||||||
* not expose (avatar, name pool, respond-to allowlist, parallelism) are preserved rather
|
* not expose (ACP command, name pool, respond-to allowlist, parallelism) are preserved rather
|
||||||
* than dropped on the next publish. The slug (the `d` tag) is immutable once chosen — a
|
* than dropped on the next publish, and the new head is stamped after the one it replaces. The
|
||||||
* different slug is a different persona.
|
* slug (the `d` tag) is immutable once chosen — a different slug is a different persona.
|
||||||
*
|
*
|
||||||
* Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when
|
* Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when
|
||||||
* none are known), so the workspace and other members see it.
|
* none are known). The relay serves it to other members only when [FormState.shared] is on
|
||||||
|
* (the `["shared","true"]` tag); otherwise only the owner's own devices can read it.
|
||||||
*/
|
*/
|
||||||
class AgentPersonaEditViewModel : ViewModel() {
|
class AgentPersonaEditViewModel : ViewModel() {
|
||||||
@Volatile private var account: Account? = null
|
@Volatile private var account: Account? = null
|
||||||
|
|
||||||
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
|
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
|
||||||
private var existing: PersonaContent? = null
|
private var existing: PersonaContent? = null
|
||||||
|
private var existingCreatedAt: Long? = null
|
||||||
private var editingSlug: String? = null
|
private var editingSlug: String? = null
|
||||||
|
|
||||||
private val _state = MutableStateFlow(FormState())
|
private val _state = MutableStateFlow(FormState())
|
||||||
@@ -74,6 +76,7 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
val event = note?.event as? PersonaEvent ?: return
|
val event = note?.event as? PersonaEvent ?: return
|
||||||
val content = event.personaOrNull() ?: return
|
val content = event.personaOrNull() ?: return
|
||||||
existing = content
|
existing = content
|
||||||
|
existingCreatedAt = event.createdAt
|
||||||
editingSlug = slug
|
editingSlug = slug
|
||||||
_state.value =
|
_state.value =
|
||||||
FormState(
|
FormState(
|
||||||
@@ -85,6 +88,9 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
runtime = content.runtime.orEmpty(),
|
runtime = content.runtime.orEmpty(),
|
||||||
provider = content.provider.orEmpty(),
|
provider = content.provider.orEmpty(),
|
||||||
avatarUrl = content.avatarUrl.orEmpty(),
|
avatarUrl = content.avatarUrl.orEmpty(),
|
||||||
|
description = content.description.orEmpty(),
|
||||||
|
shared = event.isShared(),
|
||||||
|
threadSessions = content.isThreadSessionPolicy(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +108,12 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
|
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
|
||||||
|
|
||||||
|
fun onDescriptionChange(v: String) = _state.update { it.copy(description = v.take(PersonaContent.DESCRIPTION_MAX_CHARS), error = null) }
|
||||||
|
|
||||||
|
fun onSharedChange(v: Boolean) = _state.update { it.copy(shared = v, error = null) }
|
||||||
|
|
||||||
|
fun onThreadSessionsChange(v: Boolean) = _state.update { it.copy(threadSessions = v, error = null) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and
|
* Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and
|
||||||
* publishes it to the Buzz relays. Calls [onDone] on the main thread on success.
|
* publishes it to the Buzz relays. Calls [onDone] on the main thread on success.
|
||||||
@@ -127,14 +139,19 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
val content =
|
val content =
|
||||||
base.copy(
|
base.copy(
|
||||||
displayName = current.displayName.trim(),
|
displayName = current.displayName.trim(),
|
||||||
systemPrompt = current.systemPrompt.blankToNull(),
|
// Buzz writes an empty prompt as "" (not absent) to keep the content hash
|
||||||
|
// stable, so a blank field keeps whichever form the persona already had.
|
||||||
|
systemPrompt = current.systemPrompt.blankToNull() ?: base.systemPrompt?.takeIf { it.isBlank() },
|
||||||
model = current.model.blankToNull(),
|
model = current.model.blankToNull(),
|
||||||
runtime = current.runtime.blankToNull(),
|
runtime = current.runtime.blankToNull(),
|
||||||
provider = current.provider.blankToNull(),
|
provider = current.provider.blankToNull(),
|
||||||
avatarUrl = current.avatarUrl.blankToNull(),
|
avatarUrl = current.avatarUrl.blankToNull(),
|
||||||
|
description = current.description.blankToNull(),
|
||||||
|
// Absent is the default `channel` policy; only `thread` is written.
|
||||||
|
sessionPolicy = if (current.threadSessions) PersonaContent.SESSION_POLICY_THREAD else null,
|
||||||
)
|
)
|
||||||
|
|
||||||
val template = PersonaEvent.build(content, slug)
|
val template = PersonaEvent.build(content, slug, shared = current.shared, priorHeadCreatedAt = existingCreatedAt)
|
||||||
account.signAndSendPrivatelyOrBroadcast(template) {
|
account.signAndSendPrivatelyOrBroadcast(template) {
|
||||||
BuzzRelayDialect.flow.value
|
BuzzRelayDialect.flow.value
|
||||||
.toList()
|
.toList()
|
||||||
@@ -161,6 +178,12 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
val runtime: String = "",
|
val runtime: String = "",
|
||||||
val provider: String = "",
|
val provider: String = "",
|
||||||
val avatarUrl: String = "",
|
val avatarUrl: String = "",
|
||||||
|
/** Short public description, at most [PersonaContent.DESCRIPTION_MAX_CHARS] characters. */
|
||||||
|
val description: String = "",
|
||||||
|
/** Publish to the community catalog (`["shared","true"]`) instead of owner-only. */
|
||||||
|
val shared: Boolean = false,
|
||||||
|
/** A separate ACP conversation per channel thread (`session_policy: "thread"`). */
|
||||||
|
val threadSessions: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+62
-15
@@ -53,25 +53,32 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCanvasWriter
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_body_label
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_body_label
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_conflict
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_edit
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_edit
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_empty
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_empty
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_head_in_future
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save_failed
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_title
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
|
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer
|
import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -87,8 +94,10 @@ import kotlinx.coroutines.withContext
|
|||||||
* re-composes off `canvasUpdates` when a newer revision lands.
|
* re-composes off `canvasUpdates` when a newer revision lands.
|
||||||
*
|
*
|
||||||
* The edit FAB flips into a plain markdown editor; saving publishes a fresh [CanvasEvent] to the
|
* The edit FAB flips into a plain markdown editor; saving publishes a fresh [CanvasEvent] to the
|
||||||
* channel's host [relayUrl] (last-write-wins on the relay too), and the consume path folds the new
|
* channel's host [relayUrl] through [BuzzCanvasWriter], which asserts the revision the editor was
|
||||||
* revision back into [BuzzWorkspaceStates] so the view updates without a manual refresh.
|
* opened on (`expected-revision`) so a concurrent edit is refused by the relay instead of silently
|
||||||
|
* overwritten. An accepted revision is folded back into [BuzzWorkspaceStates] so the view updates
|
||||||
|
* without a manual refresh; a conflict keeps the editor open with the draft intact.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BuzzCanvasScreen(
|
fun BuzzCanvasScreen(
|
||||||
@@ -119,15 +128,17 @@ fun BuzzCanvasScreen(
|
|||||||
// that Buzz's own client would never show.
|
// that Buzz's own client would never show.
|
||||||
val canEdit = channel?.event?.isBuzzDm() != true
|
val canEdit = channel?.event?.isBuzzDm() != true
|
||||||
|
|
||||||
var editing by remember { mutableStateOf(false) }
|
// The head the editor was opened on, snapshotted at edit start (not the live head): a newer
|
||||||
|
// revision landing while the editor is open must surface as a conflict, not be overwritten.
|
||||||
|
var editBase by remember { mutableStateOf<CanvasEditBase?>(null) }
|
||||||
|
|
||||||
if (editing) {
|
editBase?.let { base ->
|
||||||
CanvasEditor(
|
CanvasEditor(
|
||||||
channelId = channelId,
|
channelId = channelId,
|
||||||
relayUrl = relayUrl,
|
relayUrl = relayUrl,
|
||||||
initial = content.orEmpty(),
|
base = base,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onClose = { editing = false },
|
onClose = { editBase = null },
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -160,7 +171,10 @@ fun BuzzCanvasScreen(
|
|||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (canEdit) {
|
if (canEdit) {
|
||||||
FloatingActionButton(onClick = { editing = true }, shape = CircleShape) {
|
FloatingActionButton(
|
||||||
|
onClick = { editBase = CanvasEditBase(content.orEmpty(), canvas?.idHex, canvas?.createdAt()) },
|
||||||
|
shape = CircleShape,
|
||||||
|
) {
|
||||||
Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(Res.string.buzz_canvas_edit))
|
Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(Res.string.buzz_canvas_edit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,17 +221,24 @@ fun BuzzCanvasScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The canvas revision an edit started from: its text, and its id/created_at (null when there was no canvas). */
|
||||||
|
private class CanvasEditBase(
|
||||||
|
val content: String,
|
||||||
|
val headId: HexKey?,
|
||||||
|
val headCreatedAt: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
/** The markdown editor: a full-height text field for the canvas body with a Save action. */
|
/** The markdown editor: a full-height text field for the canvas body with a Save action. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun CanvasEditor(
|
private fun CanvasEditor(
|
||||||
channelId: String,
|
channelId: String,
|
||||||
relayUrl: String,
|
relayUrl: String,
|
||||||
initial: String,
|
base: CanvasEditBase,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var text by remember { mutableStateOf(initial) }
|
var text by remember { mutableStateOf(base.content) }
|
||||||
var saving by remember { mutableStateOf(false) }
|
var saving by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -249,15 +270,41 @@ private fun CanvasEditor(
|
|||||||
error = null
|
error = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
val outcome =
|
||||||
accountViewModel.account.signAndSendPrivatelyOrBroadcast(
|
withContext(Dispatchers.IO) {
|
||||||
CanvasEvent.build(channelId, text),
|
BuzzCanvasWriter.save(
|
||||||
) { listOf(relay) }
|
account = accountViewModel.account,
|
||||||
|
relay = relay,
|
||||||
|
channelId = channelId,
|
||||||
|
markdown = text,
|
||||||
|
headId = base.headId,
|
||||||
|
headCreatedAt = base.headCreatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (outcome) {
|
||||||
|
is BuzzCanvasWriter.Outcome.Saved -> {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
is BuzzCanvasWriter.Outcome.Conflict -> {
|
||||||
|
saving = false
|
||||||
|
error = loadStringRes(Res.string.buzz_canvas_conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
BuzzCanvasWriter.Outcome.HeadTooFarInFuture -> {
|
||||||
|
saving = false
|
||||||
|
error = loadStringRes(Res.string.buzz_canvas_head_in_future)
|
||||||
|
}
|
||||||
|
|
||||||
|
is BuzzCanvasWriter.Outcome.Failed -> {
|
||||||
|
saving = false
|
||||||
|
error = loadStringRes(Res.string.buzz_canvas_save_failed, outcome.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onClose()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
saving = false
|
saving = false
|
||||||
error = "Failed to save: ${e.message ?: e::class.simpleName}"
|
error = loadStringRes(Res.string.buzz_canvas_save_failed, e.message ?: e::class.simpleName ?: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+7
@@ -32,7 +32,9 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
|||||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_admin_kick
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_archived
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_archived
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_auto_archived
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_created
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_created
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_deleted
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_deleted
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_unarchived
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_unarchived
|
||||||
@@ -179,6 +181,11 @@ fun buzzSystemMessageText(
|
|||||||
|
|
||||||
SystemMessagePayload.DM_CREATED -> stringRes(Res.string.buzz_system_dm_created, actor)
|
SystemMessagePayload.DM_CREATED -> stringRes(Res.string.buzz_system_dm_created, actor)
|
||||||
|
|
||||||
|
// Relay-authored with no actor: the administrator who acted is deliberately not named.
|
||||||
|
SystemMessagePayload.ADMIN_KICK -> stringRes(Res.string.buzz_system_admin_kick, target)
|
||||||
|
|
||||||
|
SystemMessagePayload.CHANNEL_AUTO_ARCHIVED -> stringRes(Res.string.buzz_system_channel_auto_archived)
|
||||||
|
|
||||||
else -> stringRes(Res.string.buzz_system_unknown, actor, payload.type.replace('_', ' '))
|
else -> stringRes(Res.string.buzz_system_unknown, actor, payload.type.replace('_', ' '))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -218,7 +218,7 @@ private fun RelayGroupMembers(
|
|||||||
if (iCanModerate) {
|
if (iCanModerate) {
|
||||||
AddMemberBar(
|
AddMemberBar(
|
||||||
isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE },
|
isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE },
|
||||||
onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) },
|
onAdd = { accountViewModel.addRelayGroupUser(channel, it) },
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -1905,6 +1905,11 @@ class AccountViewModel(
|
|||||||
pubkey: HexKey,
|
pubkey: HexKey,
|
||||||
) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) }
|
) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) }
|
||||||
|
|
||||||
|
fun addRelayGroupUser(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
pubkey: HexKey,
|
||||||
|
) = launchSigner { account.relayGroups.addRelayGroupUser(channel, pubkey) }
|
||||||
|
|
||||||
fun putRelayGroupUser(
|
fun putRelayGroupUser(
|
||||||
channel: RelayGroupChannel,
|
channel: RelayGroupChannel,
|
||||||
pubkey: HexKey,
|
pubkey: HexKey,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ object RelayIdentity {
|
|||||||
val key = KeyPair()
|
val key = KeyPair()
|
||||||
file.absoluteFile.parentFile?.mkdirs()
|
file.absoluteFile.parentFile?.mkdirs()
|
||||||
val tmp = File(file.absoluteFile.parentFile, "${file.name}.tmp")
|
val tmp = File(file.absoluteFile.parentFile, "${file.name}.tmp")
|
||||||
tmp.delete()
|
Files.deleteIfExists(tmp.toPath())
|
||||||
try {
|
try {
|
||||||
Files.createFile(tmp.toPath(), PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")))
|
Files.createFile(tmp.toPath(), PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")))
|
||||||
} catch (_: UnsupportedOperationException) {
|
} catch (_: UnsupportedOperationException) {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ hand-transcribed schemas:
|
|||||||
Each feature is a sub-package following the standard Quartz per-NIP shape (`<Feature>Event`
|
Each feature is a sub-package following the standard Quartz per-NIP shape (`<Feature>Event`
|
||||||
with a `KIND` companion, a `tags/` folder of tag classes, `TagArrayBuilderExt` write-DSL
|
with a `KIND` companion, a `tags/` folder of tag classes, `TagArrayBuilderExt` write-DSL
|
||||||
verbs, and `TagArrayExt` read accessors), mirroring e.g. `nip88Polls`. There is one event
|
verbs, and `TagArrayExt` read accessors), mirroring e.g. `nip88Polls`. There is one event
|
||||||
class per kind, ~78 in total, each registered in `utils/EventFactory.kt` (except the
|
class per kind, ~84 in total, each registered in `utils/EventFactory.kt` (except the
|
||||||
conflicts below).
|
conflicts below).
|
||||||
|
|
||||||
| Package | Buzz NIP | Kind(s) |
|
| Package | Buzz NIP | Kind(s) |
|
||||||
@@ -86,15 +86,61 @@ conflicts below).
|
|||||||
| `aoObserver` | NIP-AO | 24200 |
|
| `aoObserver` | NIP-AO | 24200 |
|
||||||
| `aeEngrams` | NIP-AE | 30174 |
|
| `aeEngrams` | NIP-AE | 30174 |
|
||||||
| `apPersonas` / `teams` / `managedAgents` / `agentProfiles` | NIP-AP + agent identity | 30175 / 30176 / 30177 / 10100 |
|
| `apPersonas` / `teams` / `managedAgents` / `agentProfiles` | NIP-AP + agent identity | 30175 / 30176 / 30177 / 10100 |
|
||||||
|
| `teamCatalog` | NIP-AP team catalog (shared projection with embedded members) | 30178 |
|
||||||
| `erReminders` / `plPushLease` / `dvDmVisibility` / `wpWorkspaceProfile` | NIP-ER/PL/DV/WP | 30300 / 30350 / 30622 / 9033 |
|
| `erReminders` / `plPushLease` / `dvDmVisibility` / `wpWorkspaceProfile` | NIP-ER/PL/DV/WP | 30300 / 30350 / 30622 / 9033 |
|
||||||
| `iaIdentityArchival` / `cwChannelWindow` | NIP-IA / NIP-CW | 9035/9036/8002/8003/13535 / 39005/39006 |
|
| `iaIdentityArchival` / `cwChannelWindow` | NIP-IA / NIP-CW | 9035/9036/8002/8003/13535 / 39005/39006/39007 |
|
||||||
| `relayAdmin` / `moderation` | admin + moderation | 9030-9032 / 9040-9044, 42000 |
|
| `relayAdmin` / `moderation` | admin + moderation | 9030-9032 / 9040-9044, 42000 |
|
||||||
| `stream` / `stream.sidecars` | stream messaging | 40002-40008, 40099, 40100 / 40901, 40902 |
|
| `stream` / `stream.sidecars` | stream messaging | 40002-40008, 40099, 40100 / 40901, 40902 |
|
||||||
| `dm` / `jobs` | DMs / agent jobs | 41001, 41010-41012 / 43001-43006 |
|
| `dm` / `jobs` | DMs / agent jobs | 41001, 41010-41012 / 43001-43006 |
|
||||||
| `workflow` / `forum` / `notifications` | workflow + social | 30620, 46001-46031 / 45001-45003 / 44100, 44101 |
|
| `workflow` / `forum` / `notifications` | workflow + social | 30620, 46001-46031 / 45001-45003 / 44100, 44101 |
|
||||||
| `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 / 24134 / 48001 / 49001 |
|
| `arArtifacts` | NIP-AR channel artifacts (revision chain by `prev`) | 45010, 45011 (relay-signed removal) |
|
||||||
|
| `mpProjects` | NIP-MP multi-repo projects (global, not `h`-scoped) | 30621 |
|
||||||
|
| `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 (48104 = relay-synthesized liveness) / 24134 / 48001 / 49001 |
|
||||||
| `rsReadState` | NIP-RS | (helpers on `AppSpecificDataEvent`, kind 30078) |
|
| `rsReadState` | NIP-RS | (helpers on `AppSpecificDataEvent`, kind 30078) |
|
||||||
|
|
||||||
|
Not modelled on purpose: **NIP-PMA `kind:30179`** (private managed agent) is only reserved upstream
|
||||||
|
— its spec says relays MUST reject it for now — and **NIP-FI** federated identity is an HTTP-upgrade
|
||||||
|
header, not an event.
|
||||||
|
|
||||||
|
### Upstream sync
|
||||||
|
|
||||||
|
Last reconciled against `block/buzz` `4ef23609b` (2026-09-29); the previous full sync was
|
||||||
|
`03fe19d6` (2026-07-21). Behaviour picked up in that pass, beyond the new kinds above:
|
||||||
|
|
||||||
|
- **Read gates.** Personas (30175) and the team catalog (30178) are author-only unless the event
|
||||||
|
carries exactly `["shared","true"]` (`apPersonas/tags/SharedTag`); a malformed `shared` tag is
|
||||||
|
rejected at ingest. Persona content gained `acp_command`, `description` and `session_policy`
|
||||||
|
(upstream field order kept, so content bytes and hashes match), and a shared head publishes only a
|
||||||
|
portable harness alias (`PersonaContent.forSharedCatalog`).
|
||||||
|
- **NIP-OA time bounds** are enforced at admission against the AUTH event's `created_at`
|
||||||
|
(strictly, every clause, `kind=` ignored): `OwnerAttestation.verifyForAuthAt`. Owner keys and
|
||||||
|
signatures must be lowercase hex, and the `auth` tag exactly four elements.
|
||||||
|
- **NIP-10 threading** follows `buzz-core/src/nip10.rs`: markers need 4+ elements and a 64-hex id,
|
||||||
|
the last one wins, and a lone `root` marker is top-level (`threading/BuzzThreadMarkers`). A reply's
|
||||||
|
root must be derived with `buzzThreadRootForReplyTo` or the relay rejects it.
|
||||||
|
- **Channel messages are written as kind 9** in Buzz's tag shape (`stream/BuzzChatMessage`, mirroring
|
||||||
|
`build_message`): `h`, NIP-10 thread markers, `p` mentions, `broadcast` for a reply that also shows
|
||||||
|
in the channel. That is what Buzz's own clients write; kind 40002 is read-only now, kept so older
|
||||||
|
messages still render. `broadcast` means the same on either kind.
|
||||||
|
- **Edits (40003)** apply to any channel message kind. The relay stores them as-is; clients resolve
|
||||||
|
them: the newest edit by the message's author, or by the owner that author declares through a
|
||||||
|
verified NIP-OA `auth` tag on its kind-0 profile (`OwnerAttestation.verifiedOwnerOf`), wins. A
|
||||||
|
relay-signed message counts as written by its `actor` (else `p`). The edit's tags overlay the
|
||||||
|
original's (`stream/BuzzEditTagOverlay`): attachments only from the edit, custom emoji from the
|
||||||
|
edit when it has any, added `p` mentions merged. So an edit re-sends every attachment and emoji
|
||||||
|
the new text still uses.
|
||||||
|
- **Contextual identity names** (`identityNames/IdentityNamePolicy`, Buzz's portable v1 contract,
|
||||||
|
run against its vendored fixtures in `IdentityNamePolicyTest`): within one context (a channel's
|
||||||
|
members) a name only grows on a real collision: a person keeps it, an agent becomes `Alice’s Honey`
|
||||||
|
or `Honey (agent)`, and anyone still colliding gets an npub suffix. `commons/.../BuzzIdentityNames`
|
||||||
|
applies it per relay group; agents are authors with a 10100 profile or a verified NIP-OA owner.
|
||||||
|
- **Put-user (9000)** without a `role` tag is "no role change" on Buzz; a plain add sends none.
|
||||||
|
- **Compare-and-swap writes**: canvas (40100) and workflow definitions (30620) take
|
||||||
|
`["expected-revision", <head id>]` and the relay answers `conflict:` on a stale head. A workflow's
|
||||||
|
`d` must be a UUID.
|
||||||
|
- A REQ that hits the relay's query deadline ends with `CLOSED "error: query timed out"`, which the
|
||||||
|
pool treats as transient rather than a structural refusal.
|
||||||
|
|
||||||
### Kind conflicts — where a Buzz kind number is already owned
|
### Kind conflicts — where a Buzz kind number is already owned
|
||||||
|
|
||||||
Several Buzz kind numbers collide with an existing Amethyst/Nostr class. `EventFactory`
|
Several Buzz kind numbers collide with an existing Amethyst/Nostr class. `EventFactory`
|
||||||
@@ -221,7 +267,7 @@ a Buzz relay once a canvas has arrived.
|
|||||||
The **edit composer (40003)** is wired: `ChannelNewMessageViewModel` has a Buzz edit mode
|
The **edit composer (40003)** is wired: `ChannelNewMessageViewModel` has a Buzz edit mode
|
||||||
(`editBuzzMessage`/`clearBuzzEdit`) whose next send publishes a 40003 targeting the
|
(`editBuzzMessage`/`clearBuzzEdit`) whose next send publishes a 40003 targeting the
|
||||||
original (minimal, mirroring Buzz's `build_edit`); an "Edit" action gated to the user's
|
original (minimal, mirroring Buzz's `build_edit`); an "Edit" action gated to the user's
|
||||||
own 40002 messages threads to the composer through the shared chat feed via an optional
|
own messages (kind 9 on a Buzz relay, or legacy 40002) threads to the composer through the shared chat feed via an optional
|
||||||
`onWantsToEditBuzz` callback (default-null, so no other chat surface is affected), and
|
`onWantsToEditBuzz` callback (default-null, so no other chat surface is affected), and
|
||||||
`EditFieldRow` shows an editing banner. This closes the render↔create loop (edit overlays
|
`EditFieldRow` shows an editing banner. This closes the render↔create loop (edit overlays
|
||||||
already rendered).
|
already rendered).
|
||||||
|
|||||||
+4
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.quartz.buzz.agentProfiles
|
package com.vitorpamplona.quartz.buzz.agentProfiles
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
@@ -75,6 +76,9 @@ class AgentProfileEvent(
|
|||||||
companion object {
|
companion object {
|
||||||
const val KIND = 10100
|
const val KIND = 10100
|
||||||
|
|
||||||
|
/** The replaceable address of [pubKey]'s agent profile (`10100:<pubkey>:`). */
|
||||||
|
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "")
|
||||||
|
|
||||||
fun build(
|
fun build(
|
||||||
profile: AgentProfileContent,
|
profile: AgentProfileContent,
|
||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
|||||||
+41
@@ -36,6 +36,11 @@ import kotlinx.serialization.json.Json
|
|||||||
* on purpose — `null` means "the harness did not report this", not zero. Unknown JSON
|
* on purpose — `null` means "the harness did not report this", not zero. Unknown JSON
|
||||||
* fields are ignored for forward compatibility, and an unrecognized [stopReason] is
|
* fields are ignored for forward compatibility, and an unrecognized [stopReason] is
|
||||||
* kept verbatim (map it with [stopReasonOrUnknown]) rather than dropping the payload.
|
* kept verbatim (map it with [stopReasonOrUnknown]) rather than dropping the payload.
|
||||||
|
*
|
||||||
|
* [pricingIdentity] is optional but not nullable on the wire: it is present only when the
|
||||||
|
* publisher could prove the billing authority and the actually-requested model, and its absence
|
||||||
|
* means "price unknown" — never infer a price from [model], which stays the configured/session
|
||||||
|
* model.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AgentTurnMetricPayload(
|
data class AgentTurnMetricPayload(
|
||||||
@@ -50,6 +55,7 @@ data class AgentTurnMetricPayload(
|
|||||||
val cumulative: TokenCounts? = null,
|
val cumulative: TokenCounts? = null,
|
||||||
val deltaReliable: Boolean = true,
|
val deltaReliable: Boolean = true,
|
||||||
val stopReason: String? = null,
|
val stopReason: String? = null,
|
||||||
|
val pricingIdentity: PricingIdentity? = null,
|
||||||
) {
|
) {
|
||||||
/** Maps [stopReason] to a [StopReason], treating any unrecognized value as [StopReason.UNKNOWN]. */
|
/** Maps [stopReason] to a [StopReason], treating any unrecognized value as [StopReason.UNKNOWN]. */
|
||||||
fun stopReasonOrUnknown(): StopReason? = stopReason?.let { StopReason.fromWire(it) }
|
fun stopReasonOrUnknown(): StopReason? = stopReason?.let { StopReason.fromWire(it) }
|
||||||
@@ -79,9 +85,44 @@ data class AgentTurnMetricPayload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The billing identity of a turn (NIP-AM `pricingIdentity`), mirroring `PricingIdentity` in
|
||||||
|
* `agent_turn_metric.rs`. When present, [authority] and [model] are required strings; [cacheClass]
|
||||||
|
* is omitted (never null) when not applicable.
|
||||||
|
*
|
||||||
|
* Pricing lookup is an exact string match on `(authority, model)`. The Rust parser does not
|
||||||
|
* restrict [authority] to the registered set, so neither does this one — check
|
||||||
|
* [isRegisteredAuthority] before trusting it for a price.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PricingIdentity(
|
||||||
|
/** The billing namespace: an exact lowercase hostname such as `api.anthropic.com` (not the transport provider). */
|
||||||
|
val authority: String,
|
||||||
|
/** The actually-requested billable model id, as resolved at request time. */
|
||||||
|
val model: String,
|
||||||
|
/** The cache-write class (e.g. `ephemeral`), when applicable. */
|
||||||
|
val cacheClass: String? = null,
|
||||||
|
) {
|
||||||
|
/** True when [authority] is one of the NIP's registered billing namespaces. */
|
||||||
|
fun isRegisteredAuthority(): Boolean = authority in REGISTERED_AUTHORITIES
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val AUTHORITY_ANTHROPIC = "api.anthropic.com"
|
||||||
|
const val AUTHORITY_OPENAI = "api.openai.com"
|
||||||
|
const val AUTHORITY_OPENROUTER = "openrouter.ai"
|
||||||
|
|
||||||
|
/** The registered `authority` values; the set only grows by amendment to NIP-AM. */
|
||||||
|
val REGISTERED_AUTHORITIES = setOf(AUTHORITY_ANTHROPIC, AUTHORITY_OPENAI, AUTHORITY_OPENROUTER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token usage counts. All fields nullable — `null` distinguishes "not reported" from
|
* Token usage counts. All fields nullable — `null` distinguishes "not reported" from
|
||||||
* zero. Mirrors `TokenCounts` in `agent_turn_metric.rs`.
|
* zero. Mirrors `TokenCounts` in `agent_turn_metric.rs`.
|
||||||
|
*
|
||||||
|
* The cache components ([cacheReadTokens], [cacheWriteTokens]) are informational subsets of
|
||||||
|
* [inputTokens]. NIP-AM has publishers keep an explicit zero and *omit* a component they cannot
|
||||||
|
* see, so an absent cache field is "unknown" — never sum it as zero.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TokenCounts(
|
data class TokenCounts(
|
||||||
|
|||||||
+50
@@ -47,6 +47,9 @@ import kotlinx.serialization.json.Json
|
|||||||
data class PersonaContent(
|
data class PersonaContent(
|
||||||
@SerialName("display_name") val displayName: String,
|
@SerialName("display_name") val displayName: String,
|
||||||
@SerialName("system_prompt") val systemPrompt: String? = null,
|
@SerialName("system_prompt") val systemPrompt: String? = null,
|
||||||
|
// The ACP harness command the agent runs under. On a shared (catalog) head it carries only a
|
||||||
|
// portable alias - see [forSharedCatalog].
|
||||||
|
@SerialName("acp_command") val acpCommand: String? = null,
|
||||||
@SerialName("avatar_url") val avatarUrl: String? = null,
|
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||||
val runtime: String? = null,
|
val runtime: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
@@ -58,9 +61,34 @@ data class PersonaContent(
|
|||||||
@SerialName("respond_to") val respondTo: String? = null,
|
@SerialName("respond_to") val respondTo: String? = null,
|
||||||
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("respond_to_allowlist") val respondToAllowlist: List<String> = emptyList(),
|
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("respond_to_allowlist") val respondToAllowlist: List<String> = emptyList(),
|
||||||
val parallelism: Int? = null,
|
val parallelism: Int? = null,
|
||||||
|
// Appended after the original fields upstream so a record without them serializes
|
||||||
|
// byte-identically to the pre-revision era. [description] is short PUBLIC display text
|
||||||
|
// (max [DESCRIPTION_MAX_CHARS]); [sessionPolicy] is omitted for the default `channel`.
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("session_policy") val sessionPolicy: String? = null,
|
||||||
) {
|
) {
|
||||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||||
|
|
||||||
|
/** True when the agent keeps a separate ACP conversation per channel thread. */
|
||||||
|
fun isThreadSessionPolicy() = sessionPolicy == SESSION_POLICY_THREAD
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The projection Buzz publishes on a shared (`["shared","true"]`) head: a catalog reader on
|
||||||
|
* another machine can only run a portable harness alias, so an unset command becomes the
|
||||||
|
* explicit stock [DEFAULT_ACP_COMMAND] (distinguishing a reset from an omitted override) and
|
||||||
|
* a machine-local command is dropped. Mirrors `build_persona_event` in Buzz's
|
||||||
|
* `desktop/src-tauri/src/managed_agents/persona_events.rs`.
|
||||||
|
*/
|
||||||
|
fun forSharedCatalog(): PersonaContent =
|
||||||
|
copy(
|
||||||
|
acpCommand =
|
||||||
|
when {
|
||||||
|
acpCommand == null -> DEFAULT_ACP_COMMAND
|
||||||
|
isPortableAcpCommand(acpCommand) -> acpCommand
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val JSON =
|
val JSON =
|
||||||
Json {
|
Json {
|
||||||
@@ -70,5 +98,27 @@ data class PersonaContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun decodeFromJson(json: String): PersonaContent = JSON.decodeFromString(json)
|
fun decodeFromJson(json: String): PersonaContent = JSON.decodeFromString(json)
|
||||||
|
|
||||||
|
/** Buzz's stock ACP harness (`DEFAULT_ACP_COMMAND` in `managed_agents/types.rs`). */
|
||||||
|
const val DEFAULT_ACP_COMMAND = "buzz-acp"
|
||||||
|
|
||||||
|
/** The only non-default [sessionPolicy] value; anything else reads as `channel`. */
|
||||||
|
const val SESSION_POLICY_THREAD = "thread"
|
||||||
|
|
||||||
|
/** Upper bound Buzz puts on [description]. */
|
||||||
|
const val DESCRIPTION_MAX_CHARS = 280
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A harness command another machine can run: the stock `buzz-acp`, or `buzz-<name>-acp`
|
||||||
|
* with `<name>` of ASCII letters, digits, `-` or `_`, at most 255 bytes in all. Mirrors
|
||||||
|
* `is_portable_acp_command` in Buzz's `managed_agents/backend.rs`.
|
||||||
|
*/
|
||||||
|
fun isPortableAcpCommand(command: String): Boolean {
|
||||||
|
if (command == DEFAULT_ACP_COMMAND) return true
|
||||||
|
if (command.length > 255 || !command.startsWith("buzz-") || !command.endsWith("-acp")) return false
|
||||||
|
if (command.length <= "buzz-".length + "-acp".length) return false
|
||||||
|
val name = command.substring("buzz-".length, command.length - "-acp".length)
|
||||||
|
return name.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '-' || it == '_' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-2
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.quartz.buzz.apPersonas
|
package com.vitorpamplona.quartz.buzz.apPersonas
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.apPersonas.tags.SharedTag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
@@ -39,6 +40,11 @@ import kotlinx.coroutines.CancellationException
|
|||||||
*
|
*
|
||||||
* The `content` is a plaintext JSON [PersonaContent]. Ground truth for the content projection
|
* The `content` is a plaintext JSON [PersonaContent]. Ground truth for the content projection
|
||||||
* is `desktop/src-tauri/src/managed_agents/persona_events.rs`.
|
* is `desktop/src-tauri/src/managed_agents/persona_events.rs`.
|
||||||
|
*
|
||||||
|
* Read access is **author-only unless shared**: the relay serves a persona to anyone but its
|
||||||
|
* author only when it carries exactly `["shared","true"]` ([SharedTag]); otherwise it is
|
||||||
|
* silently withheld from foreign REQs, COUNTs and id lookups. Device sync reads
|
||||||
|
* `authors:[self]`, so the owner always sees their own.
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
class PersonaEvent(
|
class PersonaEvent(
|
||||||
@@ -63,6 +69,9 @@ class PersonaEvent(
|
|||||||
/** The persona slug — the `d` tag. */
|
/** The persona slug — the `d` tag. */
|
||||||
fun slug() = dTag()
|
fun slug() = dTag()
|
||||||
|
|
||||||
|
/** True when the persona is published to the community catalog (`["shared","true"]`). */
|
||||||
|
fun isShared() = SharedTag.isShared(tags)
|
||||||
|
|
||||||
/** Parses the persona configuration, or throws if the JSON is malformed. */
|
/** Parses the persona configuration, or throws if the JSON is malformed. */
|
||||||
fun persona(): PersonaContent = PersonaContent.decodeFromJson(content)
|
fun persona(): PersonaContent = PersonaContent.decodeFromJson(content)
|
||||||
|
|
||||||
@@ -77,14 +86,31 @@ class PersonaEvent(
|
|||||||
companion object {
|
companion object {
|
||||||
const val KIND = 30175
|
const val KIND = 30175
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a persona the way Buzz's `build_persona_event` does: a `d` slug tag, plus
|
||||||
|
* `["shared","true"]` when [shared]. A shared head publishes the portable catalog
|
||||||
|
* projection of its content ([PersonaContent.forSharedCatalog]).
|
||||||
|
*
|
||||||
|
* An edit should pass [priorHeadCreatedAt] (the replaced head's `created_at`) so the new
|
||||||
|
* head sorts after it even when this clock lags (`monotonic_created_at` upstream).
|
||||||
|
*/
|
||||||
fun build(
|
fun build(
|
||||||
persona: PersonaContent,
|
persona: PersonaContent,
|
||||||
slug: String,
|
slug: String,
|
||||||
createdAt: Long = TimeUtils.now(),
|
shared: Boolean = false,
|
||||||
|
priorHeadCreatedAt: Long? = null,
|
||||||
|
createdAt: Long = monotonicCreatedAt(priorHeadCreatedAt),
|
||||||
initializer: TagArrayBuilder<PersonaEvent>.() -> Unit = {},
|
initializer: TagArrayBuilder<PersonaEvent>.() -> Unit = {},
|
||||||
) = eventTemplate<PersonaEvent>(KIND, persona.encodeToJson(), createdAt) {
|
) = eventTemplate<PersonaEvent>(KIND, (if (shared) persona.forSharedCatalog() else persona).encodeToJson(), createdAt) {
|
||||||
dTag(slug)
|
dTag(slug)
|
||||||
|
if (shared) addUnique(SharedTag.assemble())
|
||||||
initializer()
|
initializer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `max(now, prior + 1)`: a replacement head never sorts behind the one it replaces. */
|
||||||
|
fun monotonicCreatedAt(priorHeadCreatedAt: Long?): Long {
|
||||||
|
val now = TimeUtils.now()
|
||||||
|
return if (priorHeadCreatedAt == null) now else maxOf(now, priorHeadCreatedAt + 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.apPersonas.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog opt-in on Buzz's shared-gated kinds (the persona `kind:30175` and the team
|
||||||
|
* catalog `kind:30178`): exactly `["shared","true"]`. Without it the relay serves the event
|
||||||
|
* to its author only; with it, to the whole community. It is a tag rather than a content field
|
||||||
|
* so toggling it leaves the content bytes (and the persona content hash) unchanged.
|
||||||
|
*
|
||||||
|
* The relay refuses any other `shared` shape at ingest (a different value, a third element, or
|
||||||
|
* a second `shared` tag), and its read gate fails closed on them. Ground truth:
|
||||||
|
* `event_is_shared` in Buzz's `buzz-core/src/kind.rs` and `validate_shared_tag` in
|
||||||
|
* `buzz-relay/src/handlers/ingest.rs`.
|
||||||
|
*/
|
||||||
|
object SharedTag {
|
||||||
|
const val TAG_NAME = "shared"
|
||||||
|
const val VALUE = "true"
|
||||||
|
|
||||||
|
fun assemble() = arrayOf(TAG_NAME, VALUE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only when [tags] carry exactly one `shared` tag and it is exactly `["shared","true"]`;
|
||||||
|
* any malformed or duplicated `shared` tag reads as not shared, like the relay's read gate.
|
||||||
|
*/
|
||||||
|
fun isShared(tags: TagArray): Boolean {
|
||||||
|
var count = 0
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.isEmpty() || tag[0] != TAG_NAME) continue
|
||||||
|
if (tag.size != 2 || tag[1] != VALUE) return false
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastAny
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authoritative NIP-AR envelope of a revision, independent of any client's content
|
||||||
|
* schema. Mirrors `ArtifactEnvelope` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ArtifactEnvelope(
|
||||||
|
/** Community-local stable identity — the `d` UUID. */
|
||||||
|
val id: String,
|
||||||
|
/** Home channel — the `h` UUID. */
|
||||||
|
val home: String,
|
||||||
|
/** Immutable namespaced type name. */
|
||||||
|
val type: String,
|
||||||
|
val op: ArtifactOp,
|
||||||
|
/** The revision this one replaces; null exactly on `create`. */
|
||||||
|
val prev: HexKey?,
|
||||||
|
/** Optional conversation anchor. */
|
||||||
|
val root: HexKey?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The outcome of [ArtifactValidator.validate]. */
|
||||||
|
sealed interface ArtifactValidation {
|
||||||
|
data class Valid(
|
||||||
|
val envelope: ArtifactEnvelope,
|
||||||
|
) : ArtifactValidation
|
||||||
|
|
||||||
|
/** [reason] is upstream's rejection message, verbatim. */
|
||||||
|
data class Invalid(
|
||||||
|
val reason: String,
|
||||||
|
) : ArtifactValidation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-AR envelope validation — a line-for-line port of `validate` in Buzz's
|
||||||
|
* `buzz-core/src/artifact.rs`, which the relay runs on every `kind:45010` before
|
||||||
|
* authorization. Payloads (content and client tags) stay opaque; NIP-OA `auth` tag
|
||||||
|
* verification is left to the relay, as upstream does.
|
||||||
|
*/
|
||||||
|
object ArtifactValidator {
|
||||||
|
/** Maximum artifact tags, including the envelope. */
|
||||||
|
const val MAX_TAGS = 256
|
||||||
|
|
||||||
|
/** Maximum UTF-8 bytes in a tag name. */
|
||||||
|
const val MAX_TAG_NAME_BYTES = 128
|
||||||
|
|
||||||
|
/** Maximum UTF-8 bytes in each tag element. */
|
||||||
|
const val MAX_TAG_VALUE_BYTES = 4096
|
||||||
|
|
||||||
|
/** Maximum UTF-8 bytes across all tag elements. */
|
||||||
|
const val MAX_TAG_BYTES = 65536
|
||||||
|
|
||||||
|
/** The NIP-OA attestation tag, the only non-envelope tag a delete may carry. */
|
||||||
|
const val AUTH_TAG_NAME = "auth"
|
||||||
|
|
||||||
|
/** Envelope tag names: each at most once, with exactly two elements. */
|
||||||
|
val ENVELOPE_TAG_NAMES =
|
||||||
|
setOf(
|
||||||
|
VersionTag.TAG_NAME,
|
||||||
|
DTag.TAG_NAME,
|
||||||
|
GroupIdTag.TAG_NAME,
|
||||||
|
TypeTag.TAG_NAME,
|
||||||
|
TitleTag.TAG_NAME,
|
||||||
|
OpTag.TAG_NAME,
|
||||||
|
RootTag.TAG_NAME,
|
||||||
|
PrevTag.TAG_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun validate(
|
||||||
|
tags: TagArray,
|
||||||
|
content: String,
|
||||||
|
): ArtifactValidation {
|
||||||
|
if (tags.size > MAX_TAGS) return invalid("too many tags")
|
||||||
|
|
||||||
|
val fields = HashMap<String, String>()
|
||||||
|
var bytes = 0
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.isEmpty()) return invalid("empty tag")
|
||||||
|
val name = tag[0]
|
||||||
|
if (name.utf8Size() > MAX_TAG_NAME_BYTES) return invalid("tag name too long")
|
||||||
|
for (value in tag) {
|
||||||
|
val size = value.utf8Size()
|
||||||
|
bytes += size
|
||||||
|
if (size > MAX_TAG_VALUE_BYTES) return invalid("tag value too long")
|
||||||
|
}
|
||||||
|
if (name in ENVELOPE_TAG_NAMES && (tag.size != 2 || fields.put(name, tag[1]) != null)) {
|
||||||
|
return invalid("envelope tags must occur once with exactly two elements")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes > MAX_TAG_BYTES) return invalid("total tag bytes exceeded")
|
||||||
|
|
||||||
|
val version = fields[VersionTag.TAG_NAME] ?: return missing()
|
||||||
|
if (version != VersionTag.CURRENT) return invalid("unsupported artifact envelope version")
|
||||||
|
|
||||||
|
val id = fields[DTag.TAG_NAME] ?: return missing()
|
||||||
|
if (!ArtifactIds.isCanonicalUuid(id)) return invalidUuid(id)
|
||||||
|
val home = fields[GroupIdTag.TAG_NAME] ?: return missing()
|
||||||
|
if (!ArtifactIds.isCanonicalUuid(home)) return invalidUuid(home)
|
||||||
|
|
||||||
|
val type = fields[TypeTag.TAG_NAME] ?: return missing()
|
||||||
|
if (!TypeTag.isValid(type)) return invalid("invalid namespaced artifact type")
|
||||||
|
|
||||||
|
val opCode = fields[OpTag.TAG_NAME] ?: return missing()
|
||||||
|
val op = ArtifactOp.parse(opCode) ?: return invalid("invalid artifact operation")
|
||||||
|
|
||||||
|
val prev = fields[PrevTag.TAG_NAME]
|
||||||
|
if (prev != null && !ArtifactIds.isEventId(prev)) return invalid("event ID must be 64 lowercase hex characters")
|
||||||
|
if ((op == ArtifactOp.CREATE) != (prev == null)) return invalid("prev required exactly on non-create revisions")
|
||||||
|
|
||||||
|
val root = fields[RootTag.TAG_NAME]
|
||||||
|
if (root != null && !ArtifactIds.isEventId(root)) return invalid("event ID must be 64 lowercase hex characters")
|
||||||
|
|
||||||
|
if (op == ArtifactOp.DELETE) {
|
||||||
|
if (TitleTag.TAG_NAME in fields || content.isNotEmpty()) {
|
||||||
|
return invalid("delete must omit title and have empty content")
|
||||||
|
}
|
||||||
|
if (tags.fastAny { it[0] !in ENVELOPE_TAG_NAMES && it[0] != AUTH_TAG_NAME }) {
|
||||||
|
return invalid("delete allows only envelope and verified auth tags")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val title = fields[TitleTag.TAG_NAME] ?: return missing()
|
||||||
|
if (!TitleTag.isValid(title)) return invalid("title must be nonblank and at most 512 UTF-8 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ArtifactValidation.Valid(ArtifactEnvelope(id, home, type, op, prev, root))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.utf8Size() = encodeToByteArray().size
|
||||||
|
|
||||||
|
private fun invalid(reason: String) = ArtifactValidation.Invalid(reason)
|
||||||
|
|
||||||
|
private fun missing() = invalid("missing required envelope tag")
|
||||||
|
|
||||||
|
// `canonical_uuid` distinguishes an unparseable value from a non-canonical one.
|
||||||
|
private fun invalidUuid(value: String) =
|
||||||
|
if (UUID_ANY_FORM.matches(value)) {
|
||||||
|
invalid("UUID must be canonical lowercase and non-nil")
|
||||||
|
} else {
|
||||||
|
invalid("invalid UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val UUID_ANY_FORM = Regex("^(urn:uuid:)?\\{?[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}}?$")
|
||||||
|
}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz NIP-AR artifact revision (`kind:45010`): one complete snapshot of an editable,
|
||||||
|
* channel-homed record (a task, a project card, …). User-signed and **regular**, not
|
||||||
|
* addressable — identity is `(community, d)` and the relay advances a per-identity head
|
||||||
|
* atomically, so every non-create revision must name the current head in `prev`
|
||||||
|
* (compare-and-swap; timestamps never pick a winner, see [ArtifactHeadResolver]).
|
||||||
|
*
|
||||||
|
* Envelope tags, each exactly once with two elements: `ar`=`1`, `d` (lowercase UUID), `h`
|
||||||
|
* (home channel UUID), `type` (namespaced, immutable), `title` (absent on delete), `op`,
|
||||||
|
* optional `root` (conversation anchor), and `prev` (required iff `op != create`). Any other
|
||||||
|
* tag is a client-defined, relay-matchable annotation ([clientTags]); `content` is opaque
|
||||||
|
* and empty on delete. At most 256 tags.
|
||||||
|
*
|
||||||
|
* Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs` (mirrored by
|
||||||
|
* [ArtifactValidator]), `accept_artifact` in `buzz-db/src/store/artifact.rs`, and
|
||||||
|
* `docs/nips/NIP-AR.md`. Buzz ships no SDK builder for this kind yet; [build] enforces the
|
||||||
|
* same envelope the relay validates.
|
||||||
|
*
|
||||||
|
* Reads: a plain REQ `{"kinds":[45010,45011],"#h":[channel]}` replays a channel's revisions
|
||||||
|
* and removals. Current-state and history views (and any multi-letter tag predicate such as
|
||||||
|
* `#project`) go through the relay's explicit artifact query (`{"artifact":"current"|"history",
|
||||||
|
* …}` on the NIP-98 HTTP `/query`); the relay rejects a REQ that mixes artifact kinds with
|
||||||
|
* multi-letter tag filters rather than silently dropping the predicate.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class ArtifactEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The artifact's stable UUID — the `d` tag. */
|
||||||
|
fun artifactId() = tags.artifactId()
|
||||||
|
|
||||||
|
/** The home channel UUID — the `h` tag. */
|
||||||
|
fun home() = tags.artifactHome()
|
||||||
|
|
||||||
|
/** The namespaced content type — the `type` tag. */
|
||||||
|
fun type() = tags.artifactType()
|
||||||
|
|
||||||
|
/** The display title — the `title` tag; null on a delete. */
|
||||||
|
fun title() = tags.artifactTitle()
|
||||||
|
|
||||||
|
/** The lifecycle operation — the `op` tag. */
|
||||||
|
fun op() = tags.artifactOp()
|
||||||
|
|
||||||
|
/** The conversation anchor — the `root` tag. */
|
||||||
|
fun root() = tags.artifactRoot()
|
||||||
|
|
||||||
|
/** The replaced revision — the `prev` tag; null on a create. */
|
||||||
|
fun prev() = tags.artifactPrev()
|
||||||
|
|
||||||
|
/** True for a soft-delete revision. */
|
||||||
|
fun isDelete() = op() == ArtifactOp.DELETE
|
||||||
|
|
||||||
|
/** The client-defined annotation tags an editor must carry forward. */
|
||||||
|
fun clientTags() = tags.artifactClientTags()
|
||||||
|
|
||||||
|
/** Runs the relay's envelope validation over this revision. */
|
||||||
|
fun validate(): ArtifactValidation = ArtifactValidator.validate(tags, content)
|
||||||
|
|
||||||
|
/** The validated envelope, or null when the relay would reject this revision. */
|
||||||
|
fun envelopeOrNull(): ArtifactEnvelope? = (validate() as? ArtifactValidation.Valid)?.envelope
|
||||||
|
|
||||||
|
fun isWellFormed() = validate() is ArtifactValidation.Valid
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 45010
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a revision and checks it against [ArtifactValidator] — including any client
|
||||||
|
* tags the [initializer] adds — so a template that the relay would reject never
|
||||||
|
* leaves this function.
|
||||||
|
*
|
||||||
|
* [title] must be null exactly when [op] is [ArtifactOp.DELETE] (and [content] then
|
||||||
|
* empty); [prev] must be null exactly when [op] is [ArtifactOp.CREATE].
|
||||||
|
*/
|
||||||
|
fun build(
|
||||||
|
artifactId: String,
|
||||||
|
channelId: String,
|
||||||
|
type: String,
|
||||||
|
op: ArtifactOp,
|
||||||
|
title: String?,
|
||||||
|
content: String,
|
||||||
|
prev: HexKey? = null,
|
||||||
|
root: HexKey? = null,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
|
||||||
|
): EventTemplate<ArtifactEvent> {
|
||||||
|
require(ArtifactIds.isCanonicalUuid(artifactId)) { "artifact id must be a lowercase, non-nil UUID" }
|
||||||
|
require(ArtifactIds.isCanonicalUuid(channelId)) { "channel id must be a lowercase, non-nil UUID" }
|
||||||
|
require(TypeTag.isValid(type)) { "invalid namespaced artifact type: $type" }
|
||||||
|
if (op == ArtifactOp.DELETE) {
|
||||||
|
require(title == null) { "a delete must omit the title" }
|
||||||
|
require(content.isEmpty()) { "a delete must have empty content" }
|
||||||
|
} else {
|
||||||
|
require(title != null && TitleTag.isValid(title)) { "title must be nonblank and at most ${TitleTag.MAX_BYTES} UTF-8 bytes" }
|
||||||
|
}
|
||||||
|
require((op == ArtifactOp.CREATE) == (prev == null)) { "prev is required exactly on non-create revisions" }
|
||||||
|
require(prev == null || ArtifactIds.isEventId(prev)) { "prev must be a 64-char lowercase hex event id" }
|
||||||
|
require(root == null || ArtifactIds.isEventId(root)) { "root must be a 64-char lowercase hex event id" }
|
||||||
|
|
||||||
|
val template =
|
||||||
|
eventTemplate<ArtifactEvent>(KIND, content, createdAt) {
|
||||||
|
artifactVersion()
|
||||||
|
artifactId(artifactId)
|
||||||
|
artifactHome(channelId)
|
||||||
|
artifactType(type)
|
||||||
|
title?.let { artifactTitle(it) }
|
||||||
|
artifactOp(op)
|
||||||
|
root?.let { artifactRoot(it) }
|
||||||
|
prev?.let { artifactPrev(it) }
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = ArtifactValidator.validate(template.tags, template.content)
|
||||||
|
require(result is ArtifactValidation.Valid) { "invalid artifact envelope: ${(result as ArtifactValidation.Invalid).reason}" }
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a new artifact identity. Pass client tags (e.g. `assignee`) via [initializer]. */
|
||||||
|
fun create(
|
||||||
|
artifactId: String,
|
||||||
|
channelId: String,
|
||||||
|
type: String,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
root: HexKey? = null,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
|
||||||
|
) = build(artifactId, channelId, type, ArtifactOp.CREATE, title, content, null, root, createdAt, initializer)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `update` on top of [current] (which must be the head the relay holds): keeps its
|
||||||
|
* `d`, `h`, `type` and `root`, names it in `prev`, and carries its client tags forward
|
||||||
|
* unless [keepClientTags] is false — NIP-AR editors must preserve annotations they do
|
||||||
|
* not understand. The [initializer] runs after the carry-over, so it can replace them.
|
||||||
|
*/
|
||||||
|
fun update(
|
||||||
|
current: ArtifactEvent,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
keepClientTags: Boolean = true,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
|
||||||
|
): EventTemplate<ArtifactEvent> {
|
||||||
|
val envelope = requireNotNull(current.envelopeOrNull()) { "current revision has an invalid envelope" }
|
||||||
|
return build(envelope.id, envelope.home, envelope.type, ArtifactOp.UPDATE, title, content, current.id, envelope.root, createdAt) {
|
||||||
|
if (keepClientTags) current.clientTags().forEach { add(it) }
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The soft delete of [current]: same `d`, `h`, `type` and `root` (the relay rejects a
|
||||||
|
* delete that changes the anchor), empty content, no title and no client tags.
|
||||||
|
*/
|
||||||
|
fun delete(
|
||||||
|
current: ArtifactEvent,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
): EventTemplate<ArtifactEvent> {
|
||||||
|
val envelope = requireNotNull(current.envelopeOrNull()) { "current revision has an invalid envelope" }
|
||||||
|
return build(envelope.id, envelope.home, envelope.type, ArtifactOp.DELETE, null, "", current.id, envelope.root, createdAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
|
||||||
|
/** What a set of revisions says about one artifact's current state. See [ArtifactHeadResolver]. */
|
||||||
|
@Immutable
|
||||||
|
sealed interface ArtifactHead {
|
||||||
|
/** No well-formed revision of the artifact was supplied. */
|
||||||
|
data object Unknown : ArtifactHead
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [revision] is the current head. [chainComplete] is true when its `prev` chain reaches the
|
||||||
|
* `create` with no missing revision; false when the head was found across a gap (a redacted,
|
||||||
|
* expired or unreadable revision), which is still unambiguous for a linear history.
|
||||||
|
*/
|
||||||
|
data class Current(
|
||||||
|
val revision: ArtifactEvent,
|
||||||
|
val chainComplete: Boolean,
|
||||||
|
) : ArtifactHead {
|
||||||
|
/** A soft-deleted artifact: hidden from active views until a `restore`. */
|
||||||
|
val isDeleted get() = revision.op() == ArtifactOp.DELETE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest visible revision, [lastRevision], was replaced by a move into a channel this
|
||||||
|
* reader cannot see — the relay-signed [removal] names it as `prev`.
|
||||||
|
*/
|
||||||
|
data class MovedAway(
|
||||||
|
val lastRevision: ArtifactEvent,
|
||||||
|
val removal: ArtifactRemovalEvent,
|
||||||
|
) : ArtifactHead
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The revisions do not determine a single head: a fork (impossible on a Buzz relay, which
|
||||||
|
* serializes every identity, so it signals revisions from elsewhere), several gaps, or a
|
||||||
|
* `prev` cycle ([candidates] is then empty). Ask the relay's current-state query
|
||||||
|
* (`{"artifact":"current","#d":[id]}`) instead of guessing.
|
||||||
|
*/
|
||||||
|
data class Ambiguous(
|
||||||
|
val candidates: List<ArtifactEvent>,
|
||||||
|
) : ArtifactHead
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an artifact's current head from its revisions the way a Buzz relay defines it: the
|
||||||
|
* latest *accepted* revision, where acceptance is a compare-and-swap on `prev` — every
|
||||||
|
* revision after the `create` names the head it replaced, so the accepted revisions form one
|
||||||
|
* linear chain. **Timestamps never choose the winner** (NIP-AR, "Identity and edits"), so
|
||||||
|
* neither does this resolver.
|
||||||
|
*
|
||||||
|
* The head is the one revision no other revision names as `prev` (a *tip*). Readers can see
|
||||||
|
* gaps — a NIP-29 `9005` redaction withholds a revision but keeps it accepted, retention
|
||||||
|
* expires old ones, and a moved artifact's earlier revisions stay under the source channel's
|
||||||
|
* access rules — which leaves more than one tip. Because the accepted history is linear, the
|
||||||
|
* segment that starts at the `create` is always the oldest, so when some other segment starts
|
||||||
|
* after a gap the create-rooted tip cannot be the head and is discarded. Anything still
|
||||||
|
* unresolved is reported as [ArtifactHead.Ambiguous] rather than picked by `created_at`.
|
||||||
|
*
|
||||||
|
* Ground truth: `accept_artifact` in Buzz's `buzz-db/src/store/artifact.rs` (the CAS),
|
||||||
|
* `removal_marker` in the same file, and `docs/nips/NIP-AR.md`.
|
||||||
|
*/
|
||||||
|
object ArtifactHeadResolver {
|
||||||
|
fun resolve(
|
||||||
|
artifactId: String,
|
||||||
|
revisions: Collection<ArtifactEvent>,
|
||||||
|
removals: Collection<ArtifactRemovalEvent> = emptyList(),
|
||||||
|
): ArtifactHead {
|
||||||
|
val byId = LinkedHashMap<HexKey, ArtifactEvent>()
|
||||||
|
val prevOf = HashMap<HexKey, HexKey?>()
|
||||||
|
val opOf = HashMap<HexKey, ArtifactOp>()
|
||||||
|
for (revision in revisions) {
|
||||||
|
if (revision.id in byId) continue
|
||||||
|
val envelope = revision.envelopeOrNull() ?: continue
|
||||||
|
if (envelope.id != artifactId) continue
|
||||||
|
byId[revision.id] = revision
|
||||||
|
prevOf[revision.id] = envelope.prev
|
||||||
|
opOf[revision.id] = envelope.op
|
||||||
|
}
|
||||||
|
if (byId.isEmpty()) return ArtifactHead.Unknown
|
||||||
|
|
||||||
|
val replaced = HashSet<HexKey>()
|
||||||
|
prevOf.values.forEach { if (it != null) replaced.add(it) }
|
||||||
|
|
||||||
|
val tips = byId.values.filter { it.id !in replaced }
|
||||||
|
if (tips.isEmpty()) return ArtifactHead.Ambiguous(emptyList())
|
||||||
|
|
||||||
|
// Walk each tip back to the oldest revision reachable from it.
|
||||||
|
val reachesCreate = tips.associateWith { tip -> opOf[segmentStart(tip.id, prevOf)] == ArtifactOp.CREATE }
|
||||||
|
|
||||||
|
val head: ArtifactEvent
|
||||||
|
val complete: Boolean
|
||||||
|
if (tips.size == 1) {
|
||||||
|
head = tips[0]
|
||||||
|
complete = reachesCreate.getValue(head)
|
||||||
|
} else {
|
||||||
|
val afterGap = tips.filter { !reachesCreate.getValue(it) }
|
||||||
|
if (afterGap.size != 1) return ArtifactHead.Ambiguous(tips)
|
||||||
|
head = afterGap[0]
|
||||||
|
complete = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val removal = removals.firstOrNull { it.artifactId() == artifactId && it.replacedRevision() == head.id }
|
||||||
|
if (removal != null) return ArtifactHead.MovedAway(head, removal)
|
||||||
|
|
||||||
|
return ArtifactHead.Current(head, complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The known revisions from [head] back along `prev`, newest first. Stops at the `create`,
|
||||||
|
* at the first revision not in [revisions], or on a cycle.
|
||||||
|
*/
|
||||||
|
fun history(
|
||||||
|
head: ArtifactEvent,
|
||||||
|
revisions: Collection<ArtifactEvent>,
|
||||||
|
): List<ArtifactEvent> {
|
||||||
|
val byId = revisions.associateBy { it.id }
|
||||||
|
val result = mutableListOf<ArtifactEvent>()
|
||||||
|
val seen = HashSet<HexKey>()
|
||||||
|
var current: ArtifactEvent? = head
|
||||||
|
while (current != null && seen.add(current.id)) {
|
||||||
|
result.add(current)
|
||||||
|
current = current.prev()?.let { byId[it] }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun segmentStart(
|
||||||
|
tip: HexKey,
|
||||||
|
prevOf: Map<HexKey, HexKey?>,
|
||||||
|
): HexKey {
|
||||||
|
val seen = HashSet<HexKey>()
|
||||||
|
var current = tip
|
||||||
|
while (seen.add(current)) {
|
||||||
|
val prev = prevOf[current] ?: return current
|
||||||
|
if (prev !in prevOf) return current
|
||||||
|
current = prev
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two identifier grammars NIP-AR's envelope uses, mirroring `canonical_uuid` and
|
||||||
|
* `event_id` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object ArtifactIds {
|
||||||
|
private val CANONICAL_UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||||
|
private const val NIL_UUID = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for a lowercase, hyphenated, non-nil UUID. Upstream parses with `Uuid::parse_str`
|
||||||
|
* and then requires `id.to_string() == value`, which only the lowercase hyphenated form
|
||||||
|
* satisfies (simple, braced, URN and uppercase forms all fail the round trip).
|
||||||
|
*/
|
||||||
|
fun isCanonicalUuid(value: String): Boolean = value.length == 36 && CANONICAL_UUID.matches(value) && value != NIL_UUID
|
||||||
|
|
||||||
|
/** True for a 64-character lowercase hex event id. */
|
||||||
|
fun isEventId(value: String): Boolean {
|
||||||
|
if (value.length != 64) return false
|
||||||
|
for (c in value) {
|
||||||
|
if (c !in '0'..'9' && c !in 'a'..'f') return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz NIP-AR artifact removal (`kind:45011`), **signed by the relay** and stored in the
|
||||||
|
* *source* channel when an artifact moves out of it, in the same transaction that accepts the
|
||||||
|
* `op=move` revision. It says only that artifact `d` left channel `h`, replacing revision
|
||||||
|
* `prev` — never the destination, title or content. Tags, in order: `["ar","1"]`,
|
||||||
|
* `["d",<artifact uuid>]`, `["h",<source channel uuid>]`, `["reason","moved"]`,
|
||||||
|
* `["prev",<replaced revision id>]`; `content` is empty.
|
||||||
|
*
|
||||||
|
* Clients never publish this kind; [build] exists for fixtures/tests. Ground truth:
|
||||||
|
* `removal_marker` in Buzz's `buzz-db/src/store/artifact.rs`.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class ArtifactRemovalEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The artifact that left the channel — the `d` tag. */
|
||||||
|
fun artifactId() = tags.artifactId()
|
||||||
|
|
||||||
|
/** The source channel the artifact was removed from — the `h` tag. */
|
||||||
|
fun sourceChannel() = tags.artifactHome()
|
||||||
|
|
||||||
|
/** Why it was removed — the `reason` tag (`moved`). */
|
||||||
|
fun reason() = tags.artifactRemovalReason()
|
||||||
|
|
||||||
|
/** The revision the move replaced (the last one readable in the source) — the `prev` tag. */
|
||||||
|
fun replacedRevision() = tags.artifactPrev()
|
||||||
|
|
||||||
|
/** True when the marker has the exact shape the relay emits. */
|
||||||
|
fun isWellFormed(): Boolean {
|
||||||
|
if (content.isNotEmpty()) return false
|
||||||
|
if (tags.artifactVersion() != VersionTag.CURRENT) return false
|
||||||
|
val id = artifactId() ?: return false
|
||||||
|
val source = sourceChannel() ?: return false
|
||||||
|
val prev = replacedRevision() ?: return false
|
||||||
|
return ArtifactIds.isCanonicalUuid(id) && ArtifactIds.isCanonicalUuid(source) && ArtifactIds.isEventId(prev) && reason() != null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 45011
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
artifactId: String,
|
||||||
|
sourceChannelId: String,
|
||||||
|
replacedRevision: HexKey,
|
||||||
|
reason: String = ReasonTag.MOVED,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ArtifactRemovalEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate<ArtifactRemovalEvent>(KIND, "", createdAt) {
|
||||||
|
artifactVersion()
|
||||||
|
artifactId(artifactId)
|
||||||
|
artifactHome(sourceChannelId)
|
||||||
|
artifactRemovalReason(reason)
|
||||||
|
artifactPrev(replacedRevision)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
|
||||||
|
/** The `ar` envelope-version tag. */
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactVersion(version: String = VersionTag.CURRENT) = addUnique(VersionTag.assemble(version))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artifact UUID as its `d` tag. Artifacts are regular (not addressable) events, so this
|
||||||
|
* is a plain `d` tag, not the NIP-01 addressable identifier.
|
||||||
|
*/
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactId(artifactId: String) = addUnique(DTag.assemble(artifactId))
|
||||||
|
|
||||||
|
/** The home (or, on a removal, source) channel — the `h` tag. */
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactHome(channelId: String) = addUnique(GroupIdTag.assemble(channelId))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactType(type: String) = addUnique(TypeTag.assemble(type))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactTitle(title: String) = addUnique(TitleTag.assemble(title))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactOp(op: ArtifactOp) = addUnique(OpTag.assemble(op))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactRoot(anchorEventId: HexKey) = addUnique(RootTag.assemble(anchorEventId))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactPrev(revisionId: HexKey) = addUnique(PrevTag.assemble(revisionId))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactRemovalReason(reason: String = ReasonTag.MOVED) = addUnique(ReasonTag.assemble(reason))
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
|
||||||
|
/** The envelope version — the `ar` tag. */
|
||||||
|
fun TagArray.artifactVersion(): String? = fastFirstNotNullOfOrNull(VersionTag::parse)
|
||||||
|
|
||||||
|
/** The artifact's stable UUID — the `d` tag. */
|
||||||
|
fun TagArray.artifactId(): String? = firstTagValue(DTag.TAG_NAME)
|
||||||
|
|
||||||
|
/** The artifact's home channel UUID (the source channel on a removal) — the `h` tag. */
|
||||||
|
fun TagArray.artifactHome(): String? = fastFirstNotNullOfOrNull(GroupIdTag::parse)
|
||||||
|
|
||||||
|
/** The namespaced content type — the `type` tag. */
|
||||||
|
fun TagArray.artifactType(): String? = fastFirstNotNullOfOrNull(TypeTag::parse)
|
||||||
|
|
||||||
|
/** The display title — the `title` tag (absent on a delete). */
|
||||||
|
fun TagArray.artifactTitle(): String? = fastFirstNotNullOfOrNull(TitleTag::parse)
|
||||||
|
|
||||||
|
/** The lifecycle operation — the `op` tag. */
|
||||||
|
fun TagArray.artifactOp(): ArtifactOp? = fastFirstNotNullOfOrNull(OpTag::parse)
|
||||||
|
|
||||||
|
/** The conversation anchor — the `root` tag. */
|
||||||
|
fun TagArray.artifactRoot(): HexKey? = fastFirstNotNullOfOrNull(RootTag::parse)
|
||||||
|
|
||||||
|
/** The replaced revision — the `prev` tag. */
|
||||||
|
fun TagArray.artifactPrev(): HexKey? = fastFirstNotNullOfOrNull(PrevTag::parse)
|
||||||
|
|
||||||
|
/** Why a removal (`kind:45011`) was issued — the `reason` tag. */
|
||||||
|
fun TagArray.artifactRemovalReason(): String? = fastFirstNotNullOfOrNull(ReasonTag::parse)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client-defined tags: every tag that is neither envelope nor NIP-OA `auth`. NIP-AR
|
||||||
|
* editors MUST preserve these (and unfamiliar content fields) or decline the edit.
|
||||||
|
*/
|
||||||
|
fun TagArray.artifactClientTags(): List<Array<String>> = filter { it.isNotEmpty() && it[0] !in ArtifactValidator.ENVELOPE_TAG_NAMES && it[0] != ArtifactValidator.AUTH_TAG_NAME }
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lifecycle operation a NIP-AR revision performs, carried by the `op` tag ([OpTag]).
|
||||||
|
* Ground truth: `ArtifactOp` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
enum class ArtifactOp(
|
||||||
|
val code: String,
|
||||||
|
) {
|
||||||
|
/** First revision of a new identity; the only op without `prev`. */
|
||||||
|
CREATE("create"),
|
||||||
|
|
||||||
|
/** Content change within the same home channel. */
|
||||||
|
UPDATE("update"),
|
||||||
|
|
||||||
|
/** Content snapshot published into a new home channel. */
|
||||||
|
MOVE("move"),
|
||||||
|
|
||||||
|
/** Soft delete: empty content, no `title`; only [RESTORE] may follow. */
|
||||||
|
DELETE("delete"),
|
||||||
|
|
||||||
|
/** Complete snapshot that revives a deleted artifact. */
|
||||||
|
RESTORE("restore"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parse(code: String): ArtifactOp? =
|
||||||
|
when (code) {
|
||||||
|
CREATE.code -> CREATE
|
||||||
|
UPDATE.code -> UPDATE
|
||||||
|
MOVE.code -> MOVE
|
||||||
|
DELETE.code -> DELETE
|
||||||
|
RESTORE.code -> RESTORE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/** The NIP-AR `op` tag — the revision's [ArtifactOp]. */
|
||||||
|
object OpTag {
|
||||||
|
const val TAG_NAME = "op"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): ArtifactOp? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
return ArtifactOp.parse(tag[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(op: ArtifactOp) = arrayOf(TAG_NAME, op.code)
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `prev` tag — the id of the revision this one replaces. Required on every op
|
||||||
|
* except `create`, where it MUST be absent; the relay accepts a revision only when `prev`
|
||||||
|
* names the artifact's current head (compare-and-swap). The relay-signed removal
|
||||||
|
* (`kind:45011`) reuses it for the revision a move replaced. Ground truth: `validate` in
|
||||||
|
* `buzz-core/src/artifact.rs`, `accept_artifact` in `buzz-db/src/store/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object PrevTag {
|
||||||
|
const val TAG_NAME = "prev"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): HexKey? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(revisionId: HexKey) = arrayOf(TAG_NAME, revisionId)
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `reason` tag on a relay-signed NIP-AR removal (`kind:45011`). The relay only ever
|
||||||
|
* emits [MOVED]. Ground truth: `removal_marker` in Buzz's `buzz-db/src/store/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object ReasonTag {
|
||||||
|
const val TAG_NAME = "reason"
|
||||||
|
const val MOVED = "moved"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(reason: String = MOVED) = arrayOf(TAG_NAME, reason)
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `root` tag — an optional conversation anchor (a `kind:9`, `40002`, `45001` or
|
||||||
|
* `45003` event in the home channel) the artifact is attached to. When set on creation, or
|
||||||
|
* changed from the previous revision, the relay requires the anchor to exist, undeleted, in
|
||||||
|
* `h`; a delete must keep the current value. Ground truth: `accept_artifact` in
|
||||||
|
* `buzz-db/src/store/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object RootTag {
|
||||||
|
const val TAG_NAME = "root"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): HexKey? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(anchorEventId: HexKey) = arrayOf(TAG_NAME, anchorEventId)
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `title` tag — the artifact's display title. Required, nonblank and at most
|
||||||
|
* [MAX_BYTES] UTF-8 bytes on every revision except `op=delete`, where it MUST be absent.
|
||||||
|
* Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object TitleTag {
|
||||||
|
const val TAG_NAME = "title"
|
||||||
|
const val MAX_BYTES = 512
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(title: String) = arrayOf(TAG_NAME, title)
|
||||||
|
|
||||||
|
/** Nonblank and within [MAX_BYTES] UTF-8 bytes. */
|
||||||
|
fun isValid(title: String) = title.isNotBlank() && title.encodeToByteArray().size <= MAX_BYTES
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `type` tag — the artifact's immutable, namespaced content type (e.g.
|
||||||
|
* `buzz.task`, `buzz.project`). `buzz.*` is reserved for published Buzz client contracts.
|
||||||
|
* Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object TypeTag {
|
||||||
|
const val TAG_NAME = "type"
|
||||||
|
const val MAX_BYTES = 128
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(type: String) = arrayOf(TAG_NAME, type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type grammar: at most [MAX_BYTES] bytes, at least one `.`, and every dot-separated
|
||||||
|
* component non-empty, starting with `a-z`, and otherwise only `a-z`, `0-9`, `_` or `-`.
|
||||||
|
*/
|
||||||
|
fun isValid(type: String): Boolean {
|
||||||
|
if (type.length > MAX_BYTES || '.' !in type) return false
|
||||||
|
var componentStart = true
|
||||||
|
for (c in type) {
|
||||||
|
if (c == '.') {
|
||||||
|
if (componentStart) return false
|
||||||
|
componentStart = true
|
||||||
|
} else if (componentStart) {
|
||||||
|
if (c !in 'a'..'z') return false
|
||||||
|
componentStart = false
|
||||||
|
} else if (c !in 'a'..'z' && c !in '0'..'9' && c != '_' && c != '-') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !componentStart
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `ar` tag — the artifact envelope version. Only [CURRENT] (`"1"`) is defined;
|
||||||
|
* relays reject any other value ("unsupported artifact envelope version"). Carried by both
|
||||||
|
* the revision (`kind:45010`) and the relay-signed removal (`kind:45011`). Ground truth:
|
||||||
|
* `validate` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object VersionTag {
|
||||||
|
const val TAG_NAME = "ar"
|
||||||
|
const val CURRENT = "1"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(version: String = CURRENT) = arrayOf(TAG_NAME, version)
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.cwChannelWindow
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON `content` of a Buzz NIP-CW thread-bounds overlay (`kind:39007`):
|
||||||
|
* `{"version":1,"direction":"older","has_more":<bool>,"next_cursor":{"created_at":<s>,"id":<hex>}|null}`.
|
||||||
|
* It is the sole authority on a thread window's exhaustion; clients echo [nextCursor] back as
|
||||||
|
* `until` + `before_id` to request the next (older) page. [isConsistent] carries the NIP's
|
||||||
|
* client-side checks on the body. Ground truth: `buzz-relay/src/api/bridge/thread_window.rs`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ThreadWindowBoundsContent(
|
||||||
|
val version: Int = VERSION,
|
||||||
|
val direction: String = DIRECTION_OLDER,
|
||||||
|
@SerialName("has_more") val hasMore: Boolean,
|
||||||
|
@SerialName("next_cursor") val nextCursor: NextCursor? = null,
|
||||||
|
) {
|
||||||
|
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||||
|
|
||||||
|
/** Version 1, direction `older`, and a cursor present exactly when [hasMore]. */
|
||||||
|
fun isConsistent() = version == VERSION && direction == DIRECTION_OLDER && (nextCursor != null) == hasMore
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VERSION = 1
|
||||||
|
const val DIRECTION_OLDER = "older"
|
||||||
|
|
||||||
|
// The relay writes `"next_cursor":null` on an exhausted page, so nulls stay explicit.
|
||||||
|
val JSON =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeFromJson(json: String): ThreadWindowBoundsContent = JSON.decodeFromString(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.cwChannelWindow
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz NIP-CW thread-bounds overlay (`kind:39007`), **signed by the relay** and synthesized
|
||||||
|
* per thread-mode query (never stored; the relay rejects it at ingest). Exactly one is
|
||||||
|
* appended to every served thread window — including empty and exhausted pages — and it is
|
||||||
|
* the only authority on the thread window's exhaustion. Deliberately distinct from the
|
||||||
|
* channel-mode [WindowBoundsEvent] (`39006`), whose channel/cursor key cannot tell concurrent
|
||||||
|
* roots apart.
|
||||||
|
*
|
||||||
|
* Tags are exactly `["d","tw:1:<sha256 hex>"]` (the request binding, see
|
||||||
|
* [ThreadWindowRequest.binding]), `["h",<channel uuid>]` and `["e",<root id>]`; `content` is a
|
||||||
|
* [ThreadWindowBoundsContent]. Before trusting it a client MUST check the relay signer and
|
||||||
|
* signature (the caller's job), the exact tags and binding ([matches]), and the body
|
||||||
|
* ([ThreadWindowBoundsContent.isConsistent]). Clients never publish this kind; [build] exists
|
||||||
|
* for fixtures/tests. Ground truth: `buzz-relay/src/api/bridge/thread_window.rs`,
|
||||||
|
* `buzz-core/src/thread_window.rs`, `docs/nips/NIP-CW.md` §Thread Bounds.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class ThreadWindowBoundsEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The request binding — the `d` tag, `tw:1:<sha256 hex>`. */
|
||||||
|
fun binding() = dTag()
|
||||||
|
|
||||||
|
/** The channel id — the `h` tag. */
|
||||||
|
fun channelId() = tags.firstNotNullOfOrNull(GroupIdTag::parse)
|
||||||
|
|
||||||
|
/** The thread root id — the `e` tag. */
|
||||||
|
fun rootId() = tags.firstNotNullOfOrNull(ETag::parseId)
|
||||||
|
|
||||||
|
/** Parses the JSON bounds [content]. Throws on malformed content; use [boundsOrNull]. */
|
||||||
|
fun bounds(): ThreadWindowBoundsContent = ThreadWindowBoundsContent.decodeFromJson(content)
|
||||||
|
|
||||||
|
fun boundsOrNull(): ThreadWindowBoundsContent? =
|
||||||
|
try {
|
||||||
|
bounds()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the tags are exactly one two-element `d`, `h` and `e` (and nothing else), the
|
||||||
|
* `d` is a `tw:1:` binding, the `h` a lowercase hyphenated UUID, the `e` a lowercase
|
||||||
|
* 64-hex id, and the body is consistent. Says nothing about which request it answers —
|
||||||
|
* use [matches] for that.
|
||||||
|
*/
|
||||||
|
fun isWellFormed(): Boolean {
|
||||||
|
if (tags.size != 3) return false
|
||||||
|
var d: String? = null
|
||||||
|
var h: String? = null
|
||||||
|
var e: String? = null
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.size != 2) return false
|
||||||
|
when (tag[0]) {
|
||||||
|
DTag.TAG_NAME -> if (d == null) d = tag[1] else return false
|
||||||
|
GroupIdTag.TAG_NAME -> if (h == null) h = tag[1] else return false
|
||||||
|
ETag.TAG_NAME -> if (e == null) e = tag[1] else return false
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d == null || h == null || e == null) return false
|
||||||
|
if (!d.startsWith(ThreadWindowRequest.BINDING_PREFIX) || !isLowercaseHex(d.substring(ThreadWindowRequest.BINDING_PREFIX.length), 64)) return false
|
||||||
|
if (!CANONICAL_UUID.matches(h) || !isLowercaseHex(e, 64)) return false
|
||||||
|
return boundsOrNull()?.isConsistent() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this well-formed overlay answers [request], as issued by [readerPubKey] to the
|
||||||
|
* relay at [host]: the binding, channel and root all match.
|
||||||
|
*/
|
||||||
|
fun matches(
|
||||||
|
request: ThreadWindowRequest,
|
||||||
|
host: String,
|
||||||
|
readerPubKey: HexKey,
|
||||||
|
): Boolean =
|
||||||
|
isWellFormed() &&
|
||||||
|
binding() == request.binding(host, readerPubKey) &&
|
||||||
|
channelId() == request.channelId.lowercase() &&
|
||||||
|
rootId() == request.rootId.lowercase()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 39007
|
||||||
|
|
||||||
|
private val CANONICAL_UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||||
|
|
||||||
|
private fun isLowercaseHex(
|
||||||
|
value: String,
|
||||||
|
length: Int,
|
||||||
|
) = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }
|
||||||
|
|
||||||
|
/** Builds the overlay the relay would sign for [request] (fixtures/tests only). */
|
||||||
|
fun build(
|
||||||
|
request: ThreadWindowRequest,
|
||||||
|
host: String,
|
||||||
|
readerPubKey: HexKey,
|
||||||
|
bounds: ThreadWindowBoundsContent,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ThreadWindowBoundsEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate<ThreadWindowBoundsEvent>(KIND, bounds.encodeToJson(), createdAt) {
|
||||||
|
dTag(request.binding(host, readerPubKey))
|
||||||
|
addUnique(GroupIdTag.assemble(request.channelId.lowercase()))
|
||||||
|
addUnique(ETag.assemble(request.rootId.lowercase(), null, null))
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.cwChannelWindow
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response-affecting arguments of a NIP-CW thread-mode request (`thread_window: true`,
|
||||||
|
* served on Buzz's NIP-98 HTTP `POST /query`), normalized the way the relay normalizes them,
|
||||||
|
* so a client can recompute the request binding a [ThreadWindowBoundsEvent] must carry.
|
||||||
|
*
|
||||||
|
* [kinds] must be drawn from `9`, `40002`, `45001`, `45003` (sorted and deduplicated here);
|
||||||
|
* [limit] is 1–200 (default 50), [depth] 1–100 (default 100); [cursor] is null for the head
|
||||||
|
* page, else the previous bounds' `next_cursor`. Ground truth: `Request::parse` and
|
||||||
|
* `Request::binding` in Buzz's `buzz-core/src/thread_window.rs`.
|
||||||
|
*/
|
||||||
|
data class ThreadWindowRequest(
|
||||||
|
val channelId: String,
|
||||||
|
val rootId: HexKey,
|
||||||
|
val kinds: List<Int>,
|
||||||
|
val limit: Int = DEFAULT_LIMIT,
|
||||||
|
val depth: Int = MAX_DEPTH,
|
||||||
|
val cursor: NextCursor? = null,
|
||||||
|
val includeAux: Boolean = false,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(kinds.isNotEmpty() && kinds.all { it in ROW_KINDS }) { "thread_window supports row kinds 9, 40002, 45001, 45003 only" }
|
||||||
|
require(limit in 1..MAX_LIMIT) { "thread_window: limit must be an integer in 1..=$MAX_LIMIT" }
|
||||||
|
require(depth in 1..MAX_DEPTH) { "thread_window: depth_limit must be an integer in 1..=$MAX_DEPTH" }
|
||||||
|
require(rootId.length == 64 && rootId.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { "thread_window: expected a full 64-hex event id" }
|
||||||
|
require(cursor == null || cursor.createdAt >= 0) { "thread_window: until must be nonnegative integer seconds" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `d` value of the bounds event answering this request for [readerPubKey] on [host]:
|
||||||
|
* `"tw:1:" + hex(SHA-256(compact JSON of
|
||||||
|
* ["tw",1,"older",host,reader,channel,root,limit,depth,kinds,cursor,include_aux]))`, where
|
||||||
|
* `cursor` is `null` or `[created_at,"<id>"]`. [host] is the relay's normalized authority
|
||||||
|
* (e.g. `relay.example` or `localhost:3000`); ids and the reader are lowercased first.
|
||||||
|
*/
|
||||||
|
fun binding(
|
||||||
|
host: String,
|
||||||
|
readerPubKey: HexKey,
|
||||||
|
): String {
|
||||||
|
val canonical =
|
||||||
|
JsonArray(
|
||||||
|
listOf(
|
||||||
|
JsonPrimitive("tw"),
|
||||||
|
JsonPrimitive(1),
|
||||||
|
JsonPrimitive(ThreadWindowBoundsContent.DIRECTION_OLDER),
|
||||||
|
JsonPrimitive(host),
|
||||||
|
JsonPrimitive(readerPubKey.lowercase()),
|
||||||
|
JsonPrimitive(channelId.lowercase()),
|
||||||
|
JsonPrimitive(rootId.lowercase()),
|
||||||
|
JsonPrimitive(limit),
|
||||||
|
JsonPrimitive(depth),
|
||||||
|
JsonArray(kinds.distinct().sorted().map { JsonPrimitive(it) }),
|
||||||
|
cursor?.let { JsonArray(listOf(JsonPrimitive(it.createdAt), JsonPrimitive(it.id.lowercase()))) } ?: JsonNull,
|
||||||
|
JsonPrimitive(includeAux),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return BINDING_PREFIX + sha256(canonical.toString().encodeToByteArray()).toHexKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val BINDING_PREFIX = "tw:1:"
|
||||||
|
const val DEFAULT_LIMIT = 50
|
||||||
|
const val MAX_LIMIT = 200
|
||||||
|
const val MAX_DEPTH = 100
|
||||||
|
|
||||||
|
/** Conversation row kinds a thread window may request. */
|
||||||
|
val ROW_KINDS = setOf(9, 40002, 45001, 45003)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -29,12 +29,19 @@ import kotlinx.serialization.json.Json
|
|||||||
/**
|
/**
|
||||||
* The `content` JSON shared by every Buzz huddle-lifecycle event (`kind:48100`–`48103`):
|
* The `content` JSON shared by every Buzz huddle-lifecycle event (`kind:48100`–`48103`):
|
||||||
* the id of the ephemeral audio channel the lifecycle transition applies to. The parent
|
* the id of the ephemeral audio channel the lifecycle transition applies to. The parent
|
||||||
* (timeline) channel is carried separately in the `h` tag. Field name is snake_case on
|
* (timeline) channel is carried separately in the `h` tag. Field names are snake_case on
|
||||||
* the wire. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
|
* the wire. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
|
||||||
|
*
|
||||||
|
* The relay also stamps the huddle session's [generation] (the same value the kind-48104
|
||||||
|
* liveness answer carries, so a client can tell a restarted session from the old one) and,
|
||||||
|
* when it has them, the participant roster's [rosterRevision] and the joiner's [admissionId].
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HuddleLifecycleContent(
|
data class HuddleLifecycleContent(
|
||||||
@SerialName("ephemeral_channel_id") val ephemeralChannelId: String,
|
@SerialName("ephemeral_channel_id") val ephemeralChannelId: String,
|
||||||
|
@SerialName("roster_revision") val rosterRevision: Long? = null,
|
||||||
|
@SerialName("admission_id") val admissionId: String? = null,
|
||||||
|
val generation: String? = null,
|
||||||
) {
|
) {
|
||||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||||
|
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.huddles
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `content` JSON of a relay-synthesized huddle-liveness snapshot (`kind:48104`):
|
||||||
|
* `{"ephemeral_channel_id": <session uuid>, "generation": <string>}`.
|
||||||
|
*
|
||||||
|
* [generation] is a **string** on the wire (the relay `to_string()`s it): the mesh lease
|
||||||
|
* generation (a monotonic decimal integer) when the relay runs a mesh, otherwise an opaque
|
||||||
|
* per-process epoch. Compare with [compareGenerations], never numerically on a `Long`.
|
||||||
|
* Ground truth: `handle_huddle_liveness_req` in Buzz's `buzz-relay/src/handlers/req.rs`;
|
||||||
|
* reader in `desktop/src/features/huddle/lib/huddlePresence.ts`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class HuddleLivenessContent(
|
||||||
|
@SerialName("ephemeral_channel_id") val ephemeralChannelId: String,
|
||||||
|
val generation: String,
|
||||||
|
) {
|
||||||
|
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val JSON =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeFromJson(json: String): HuddleLivenessContent = JSON.decodeFromString(json)
|
||||||
|
|
||||||
|
fun decodeFromJsonOrNull(json: String): HuddleLivenessContent? =
|
||||||
|
try {
|
||||||
|
decodeFromJson(json)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders two liveness generations the way Buzz's desktop does
|
||||||
|
* (`compareHuddleGenerations`): only when both are plain decimal integers (mesh
|
||||||
|
* generations, which are monotonic) is there an order, returned as -1/0/1 for
|
||||||
|
* [candidate] below/equal/above [current]. Anything else is an opaque epoch and yields
|
||||||
|
* null — a differing opaque epoch means "take a fresh snapshot", not "newer".
|
||||||
|
*/
|
||||||
|
fun compareGenerations(
|
||||||
|
candidate: String,
|
||||||
|
current: String,
|
||||||
|
): Int? {
|
||||||
|
if (!candidate.isDecimal() || !current.isDecimal()) return null
|
||||||
|
val a = candidate.trimStart('0')
|
||||||
|
val b = current.trimStart('0')
|
||||||
|
if (a.length != b.length) return if (a.length < b.length) -1 else 1
|
||||||
|
val cmp = a.compareTo(b)
|
||||||
|
return if (cmp < 0) {
|
||||||
|
-1
|
||||||
|
} else if (cmp > 0) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isDecimal() = isNotEmpty() && all { it in '0'..'9' }
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.huddles
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz huddle-liveness snapshot (`kind:48104`): the relay's authoritative statement that a
|
||||||
|
* huddle session is live right now, **synthesized and signed by the relay** per REQ and
|
||||||
|
* never stored. One event per live session: `["d",<session uuid>]` (the ephemeral audio
|
||||||
|
* channel), `["h",<parent channel uuid>]`, and a [HuddleLivenessContent] body. A session
|
||||||
|
* with no live room (or no mesh lease) simply gets no event, so absence from a snapshot is
|
||||||
|
* "not live".
|
||||||
|
*
|
||||||
|
* The relay only serves it to a REQ whose every filter has `kinds` exactly `[48104]` and
|
||||||
|
* that names at least one authorized `#h` channel — build it with [filter]. Clients never
|
||||||
|
* publish this kind; [build] exists for fixtures/tests. Ground truth:
|
||||||
|
* `filters_are_huddle_liveness_only`, `huddle_liveness_session_ids` and
|
||||||
|
* `handle_huddle_liveness_req` in Buzz's `buzz-relay/src/handlers/req.rs`.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class HuddleLivenessEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The live huddle session (its ephemeral audio channel UUID) — the `d` tag. */
|
||||||
|
fun sessionId(): String? = tags.firstTagValue(DTag.TAG_NAME)
|
||||||
|
|
||||||
|
/** The parent (timeline) channel UUID — the `h` tag. */
|
||||||
|
fun channelId(): String? = tags.huddleChannel()
|
||||||
|
|
||||||
|
/** The parsed body, or null when malformed (a malformed snapshot is not authoritative). */
|
||||||
|
fun liveness(): HuddleLivenessContent? = HuddleLivenessContent.decodeFromJsonOrNull(content)
|
||||||
|
|
||||||
|
/** The session's liveness generation, or null when missing/empty. */
|
||||||
|
fun generation(): String? = liveness()?.generation?.ifEmpty { null }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 48104
|
||||||
|
|
||||||
|
/** The relay truncates `#d` session ids (and bounds `#h` channels) to this many values. */
|
||||||
|
const val MAX_EXPLICIT_VALUES = 128
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The liveness REQ filter: `kinds` exactly `[48104]`, the parent channels as `#h`
|
||||||
|
* (required — the relay CLOSEs a liveness REQ without an authorized `#h`), and
|
||||||
|
* optionally the sessions of interest as `#d` with a matching `limit`, the shape
|
||||||
|
* Buzz's desktop sends. Without [sessionIds] the relay reports every live session it
|
||||||
|
* can link to those channels. Mixing any other kind into the filter turns it into an
|
||||||
|
* ordinary REQ that returns no liveness at all.
|
||||||
|
*/
|
||||||
|
fun filter(
|
||||||
|
channelIds: List<String>,
|
||||||
|
sessionIds: List<String>? = null,
|
||||||
|
): Filter {
|
||||||
|
require(channelIds.isNotEmpty()) { "huddle liveness requires at least one #h channel" }
|
||||||
|
require(channelIds.size <= MAX_EXPLICIT_VALUES) { "at most $MAX_EXPLICIT_VALUES channels per liveness REQ" }
|
||||||
|
require(sessionIds == null || sessionIds.size <= MAX_EXPLICIT_VALUES) { "at most $MAX_EXPLICIT_VALUES sessions per liveness REQ" }
|
||||||
|
|
||||||
|
val tags =
|
||||||
|
if (sessionIds.isNullOrEmpty()) {
|
||||||
|
mapOf(GroupIdTag.TAG_NAME to channelIds)
|
||||||
|
} else {
|
||||||
|
mapOf(GroupIdTag.TAG_NAME to channelIds, DTag.TAG_NAME to sessionIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Filter(
|
||||||
|
kinds = listOf(KIND),
|
||||||
|
tags = tags,
|
||||||
|
limit = sessionIds?.size?.takeIf { it > 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
sessionId: String,
|
||||||
|
channelId: String,
|
||||||
|
generation: String,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<HuddleLivenessEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate<HuddleLivenessEvent>(KIND, HuddleLivenessContent(sessionId, generation).encodeToJson(), createdAt) {
|
||||||
|
add(DTag.assemble(sessionId))
|
||||||
|
huddleChannel(channelId)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.identityNames
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One naming fact for an identity: its [pubkey], the [name] a client would show, whether it
|
||||||
|
* [isAgent], and the [ownerPubkey] an agent declares (a verified NIP-OA owner). Keys are 64-char hex,
|
||||||
|
* either case.
|
||||||
|
*/
|
||||||
|
data class NamingIdentity(
|
||||||
|
val pubkey: HexKey,
|
||||||
|
val name: String,
|
||||||
|
val isAgent: Boolean = false,
|
||||||
|
val ownerPubkey: HexKey? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A resolved display label. [qualifier] is the key suffix, when one was added. */
|
||||||
|
data class ResolvedIdentityName(
|
||||||
|
val name: String,
|
||||||
|
val qualifier: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buzz's "contextual identity names" v1: distinct display labels for the identities one view shows
|
||||||
|
* (a channel's members, a mention picker's choices), so two identities with the same name never look
|
||||||
|
* identical. A label only grows when a real collision exists in that context:
|
||||||
|
*
|
||||||
|
* 1. Within a collision, the highest-priority identity keeps its plain name: the viewer, then other
|
||||||
|
* people, then the viewer's own agents, then everyone else's agents.
|
||||||
|
* 2. A colliding agent first becomes readable: `Alice’s Honey` (its owner's name) when it is not the
|
||||||
|
* viewer's, else `Honey (agent)` when a person shares the name.
|
||||||
|
* 3. Whoever still collides gets a growing suffix of their npub: `Honey · 7xk2`.
|
||||||
|
*
|
||||||
|
* Display policy only; a label grants no authority, so keep the key for every action. Ported from
|
||||||
|
* Buzz's `mobile/lib/shared/identity_names/identity_name_policy.dart` and checked against its
|
||||||
|
* vendored portable fixtures (`IdentityNamePolicyTest`).
|
||||||
|
*/
|
||||||
|
object IdentityNamePolicy {
|
||||||
|
const val VERSION = 1
|
||||||
|
|
||||||
|
private val HEX_KEY = Regex("^[0-9a-fA-F]{64}$")
|
||||||
|
|
||||||
|
// ECMAScript String.prototype.trim code points, as the contract requires (U+0085 is kept).
|
||||||
|
private fun isContractWhitespace(c: Char): Boolean {
|
||||||
|
val u = c.code
|
||||||
|
return (u in 0x09..0x0D) ||
|
||||||
|
u == 0x20 ||
|
||||||
|
u == 0xA0 ||
|
||||||
|
u == 0x1680 ||
|
||||||
|
(u in 0x2000..0x200A) ||
|
||||||
|
u == 0x2028 ||
|
||||||
|
u == 0x2029 ||
|
||||||
|
u == 0x202F ||
|
||||||
|
u == 0x205F ||
|
||||||
|
u == 0x3000 ||
|
||||||
|
u == 0xFEFF
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trims [value] exactly as the contract specifies. */
|
||||||
|
fun trim(value: String): String {
|
||||||
|
var start = 0
|
||||||
|
var end = value.length
|
||||||
|
while (start < end && isContractWhitespace(value[start])) start++
|
||||||
|
while (end > start && isContractWhitespace(value[end - 1])) end--
|
||||||
|
return value.substring(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireKey(
|
||||||
|
key: String,
|
||||||
|
what: String,
|
||||||
|
) = require(HEX_KEY.matches(key)) { "$what is not a 64-character hex public key: $key" }
|
||||||
|
|
||||||
|
private class Row(
|
||||||
|
val key: String,
|
||||||
|
val identity: NamingIdentity,
|
||||||
|
val original: String,
|
||||||
|
val priority: Int,
|
||||||
|
val mine: Boolean,
|
||||||
|
) {
|
||||||
|
var base: String = original
|
||||||
|
var label: String = original
|
||||||
|
var length: Int = 0
|
||||||
|
var suffix: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves distinct labels for the [candidates] among [identities] (all of them when
|
||||||
|
* [candidates] is null; none for an empty set), as seen by [viewer]. Facts outside the
|
||||||
|
* candidates still serve as owner-name lookups. Throws on any invalid key.
|
||||||
|
*/
|
||||||
|
fun resolve(
|
||||||
|
identities: List<NamingIdentity>,
|
||||||
|
viewer: HexKey? = null,
|
||||||
|
candidates: Iterable<HexKey>? = null,
|
||||||
|
): Map<HexKey, ResolvedIdentityName> {
|
||||||
|
identities.forEach { identity ->
|
||||||
|
requireKey(identity.pubkey, "pubkey")
|
||||||
|
identity.ownerPubkey?.let { requireKey(it, "ownerPubkey") }
|
||||||
|
}
|
||||||
|
viewer?.let { requireKey(it, "viewer") }
|
||||||
|
val selected =
|
||||||
|
candidates
|
||||||
|
?.map {
|
||||||
|
requireKey(it, "candidates")
|
||||||
|
it.lowercase()
|
||||||
|
}?.toSet()
|
||||||
|
val normalizedViewer = viewer?.lowercase()
|
||||||
|
|
||||||
|
// Last fact per key: the preferred alias and the owner-lookup name.
|
||||||
|
val preferred = LinkedHashMap<String, NamingIdentity>()
|
||||||
|
identities.forEach { preferred[it.pubkey.lowercase()] = it }
|
||||||
|
|
||||||
|
// One working row per (key, trimmed name); the last fact supplies metadata.
|
||||||
|
val aliases = LinkedHashMap<Pair<String, String>, NamingIdentity>()
|
||||||
|
identities.forEach { aliases[it.pubkey.lowercase() to trim(it.name)] = it }
|
||||||
|
|
||||||
|
val rows =
|
||||||
|
aliases.mapNotNull { (keyAndName, identity) ->
|
||||||
|
val (key, name) = keyAndName
|
||||||
|
if (selected != null && key !in selected) return@mapNotNull null
|
||||||
|
val owner = identity.ownerPubkey?.lowercase()
|
||||||
|
val mine = normalizedViewer != null && (key == normalizedViewer || owner == normalizedViewer)
|
||||||
|
Row(
|
||||||
|
key = key,
|
||||||
|
identity = identity,
|
||||||
|
original = name,
|
||||||
|
mine = mine,
|
||||||
|
priority =
|
||||||
|
if (identity.isAgent) {
|
||||||
|
if (mine) 2 else 3
|
||||||
|
} else {
|
||||||
|
if (key == normalizedViewer) 0 else 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val npubs = HashMap<String, String>()
|
||||||
|
|
||||||
|
fun npubFor(key: String) = npubs.getOrPut(key) { NPub.create(key) }
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val groups = LinkedHashMap<String, MutableList<Row>>()
|
||||||
|
rows.forEach { groups.getOrPut(it.label) { mutableListOf() }.add(it) }
|
||||||
|
val collisions = groups.values.filter { group -> group.map { it.key }.toSet().size > 1 }
|
||||||
|
if (collisions.isEmpty()) break
|
||||||
|
|
||||||
|
for (group in collisions) {
|
||||||
|
val best = group.minOf { it.priority }
|
||||||
|
val winners = group.filter { it.priority == best }.map { it.key }.toSet()
|
||||||
|
val changing = group.filter { winners.size != 1 || it.key !in winners }
|
||||||
|
val groupHasHuman = group.any { !it.identity.isAgent }
|
||||||
|
|
||||||
|
var qualified = false
|
||||||
|
for (row in changing) {
|
||||||
|
if (!row.identity.isAgent || row.length != 0) continue
|
||||||
|
val ownerFact =
|
||||||
|
row.identity.ownerPubkey
|
||||||
|
?.lowercase()
|
||||||
|
?.let { preferred[it] }
|
||||||
|
val owner = ownerFact?.let { trim(it.name) } ?: ""
|
||||||
|
val readable =
|
||||||
|
when {
|
||||||
|
!row.mine && owner.isNotEmpty() -> "$owner’s ${row.original}"
|
||||||
|
groupHasHuman -> "${row.original} (agent)"
|
||||||
|
else -> row.base
|
||||||
|
}
|
||||||
|
if (readable != row.base) {
|
||||||
|
row.base = readable
|
||||||
|
row.label = readable
|
||||||
|
qualified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (qualified) continue
|
||||||
|
|
||||||
|
for (row in changing) {
|
||||||
|
row.length = if (row.length == 0) 4 else row.length + 1
|
||||||
|
val npub = npubFor(row.key)
|
||||||
|
val suffix = if (row.length <= npub.length) npub.substring(npub.length - row.length) else "$npub · ${row.length - npub.length}"
|
||||||
|
row.suffix = suffix
|
||||||
|
row.label = "${row.base} · $suffix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = LinkedHashMap<HexKey, ResolvedIdentityName>()
|
||||||
|
rows.forEach { row ->
|
||||||
|
if (row.original == trim(preferred.getValue(row.key).name)) {
|
||||||
|
result[row.key] = ResolvedIdentityName(row.label, row.suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-9
@@ -31,16 +31,19 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
*
|
*
|
||||||
* - `<payloadB64url>.<sigB64url>` (base64url, JWT-style but not a JWT) — the payload names the
|
* - `<payloadB64url>.<sigB64url>` (base64url, JWT-style but not a JWT) — the payload names the
|
||||||
* community, the granted role, an expiry and a nonce.
|
* community, the granted role, an expiry and a nonce.
|
||||||
* - `v2.<opaqueB64url>` — a bare server-side handle. Nothing about the invite is readable here;
|
* - `v2.<opaqueB64url>` — a bare server-side handle: `v2.` plus the unpadded, canonical base64url
|
||||||
* the relay resolves it on claim.
|
* encoding of a 32-byte secret (exactly what `validate_v2_code` in `buzz-core/src/invite.rs`
|
||||||
|
* accepts — see [BuzzInviteLink.isValidV2Code]). Nothing about the invite is readable here; the
|
||||||
|
* relay resolves it on claim.
|
||||||
*
|
*
|
||||||
* A Buzz invite is **not** a NIP-29 invite code (kind 9009) — it is redeemed over HTTP against
|
* A Buzz invite is **not** a NIP-29 invite code (kind 9009) — it is redeemed over HTTP against
|
||||||
* the relay's tenant host: `POST /api/invites/claim`, NIP-98-signed by the joining key, after
|
* the relay's tenant host: `POST /api/invites/claim`, NIP-98-signed by the joining key, after
|
||||||
* accepting any configured join policy. The relay verifies the token's MAC (its own key), so a
|
* accepting any configured join policy. The relay verifies the token's MAC (its own key), so a
|
||||||
* client only needs to read the payload, never validate the signature.
|
* client only needs to read the payload, never validate the signature.
|
||||||
*
|
*
|
||||||
* Ground truth: `buzz-relay/src/invite_token.rs` (token shape + `verify_invite`) and
|
* Ground truth: `buzz-relay/src/invite_token.rs` (token shape + `verify_invite`),
|
||||||
* `buzz-relay/src/api/invites.rs` (`claim_invite`, `accept_policy`).
|
* `buzz-core/src/invite.rs` (`validate_v2_code`) and `buzz-relay/src/api/invites.rs`
|
||||||
|
* (`claim_invite`, `accept_policy`).
|
||||||
*/
|
*/
|
||||||
data class BuzzInvite(
|
data class BuzzInvite(
|
||||||
/** The tenant host the invite is scoped to, e.g. `team.communities.buzz.xyz`. */
|
/** The tenant host the invite is scoped to, e.g. `team.communities.buzz.xyz`. */
|
||||||
@@ -74,6 +77,15 @@ object BuzzInviteLink {
|
|||||||
/** The payload segment of an opaque, server-resolved token. */
|
/** The payload segment of an opaque, server-resolved token. */
|
||||||
private const val V2_PREFIX = "v2"
|
private const val V2_PREFIX = "v2"
|
||||||
|
|
||||||
|
/** The full prefix the relay routes on (`V2_PREFIX` in `buzz-core/src/invite.rs`). */
|
||||||
|
const val V2_CODE_PREFIX = "v2."
|
||||||
|
|
||||||
|
/** Random bytes behind a `v2.` code (`V2_SECRET_LEN` in `buzz-core/src/invite.rs`). */
|
||||||
|
const val V2_SECRET_LEN = 32
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
private val B64_NO_PAD = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
|
||||||
|
|
||||||
/** What the relay grants when the token doesn't say — and it never says for `v2.`. */
|
/** What the relay grants when the token doesn't say — and it never says for `v2.`. */
|
||||||
private const val DEFAULT_ROLE = "member"
|
private const val DEFAULT_ROLE = "member"
|
||||||
|
|
||||||
@@ -115,12 +127,13 @@ object BuzzInviteLink {
|
|||||||
if (payloadB64 == code || payloadB64.isEmpty()) return null
|
if (payloadB64 == code || payloadB64.isEmpty()) return null
|
||||||
|
|
||||||
// `v2.<opaque>` carries no client-readable payload — the community, role and expiry live
|
// `v2.<opaque>` carries no client-readable payload — the community, role and expiry live
|
||||||
// only on the relay, which resolves the code on claim and returns them. There is nothing
|
// only on the relay, which resolves the code on claim and returns them. The join flow
|
||||||
// to decode and nothing to lose by admitting it: the join flow needs the host (for the
|
// needs the host (for the relay url and the REST base) and the code, both of which the url
|
||||||
// relay url and the REST base) and the code, both of which the url itself carries, and the
|
// itself carries, and the claim response supplies the rest. The relay routes on this exact
|
||||||
// claim response supplies the rest. Matched on the literal prefix rather than by relaxing
|
// prefix and never falls back to the v1 verifier, so a malformed `v2.` code is rejected
|
||||||
// the decode below, so `…/invite/anything.else` still fails to parse.
|
// here too rather than handed to a claim that can only answer `invite_invalid`.
|
||||||
if (payloadB64 == V2_PREFIX) {
|
if (payloadB64 == V2_PREFIX) {
|
||||||
|
if (!isValidV2Code(code)) return null
|
||||||
return BuzzInvite(host = host, code = code, communityId = "", role = DEFAULT_ROLE, expiresAt = null)
|
return BuzzInvite(host = host, code = code, communityId = "", role = DEFAULT_ROLE, expiresAt = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +155,24 @@ object BuzzInviteLink {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [code] is exactly `v2.` followed by the unpadded base64url encoding of a 32-byte
|
||||||
|
* secret, in its canonical form — mirroring `validate_v2_code` in `buzz-core/src/invite.rs`,
|
||||||
|
* whose decode/re-encode comparison rejects padded or otherwise aliased encodings.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun isValidV2Code(code: String): Boolean {
|
||||||
|
if (!code.startsWith(V2_CODE_PREFIX)) return false
|
||||||
|
val encoded = code.substring(V2_CODE_PREFIX.length)
|
||||||
|
val secret =
|
||||||
|
try {
|
||||||
|
B64_NO_PAD.decode(encoded)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return secret.size == V2_SECRET_LEN && B64_NO_PAD.encode(secret) == encoded
|
||||||
|
}
|
||||||
|
|
||||||
/** The host between the scheme's `//` and the `/invite/` marker, or null when absent. */
|
/** The host between the scheme's `//` and the `/invite/` marker, or null when absent. */
|
||||||
private fun extractHost(
|
private fun extractHost(
|
||||||
url: String,
|
url: String,
|
||||||
@@ -159,3 +190,49 @@ object BuzzInviteLink {
|
|||||||
return if (remainder == 0) s else s + "=".repeat(4 - remainder)
|
return if (remainder == 0) s else s + "=".repeat(4 - remainder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome vocabulary of Buzz's invite claim endpoint (`POST /api/invites/claim` in
|
||||||
|
* `buzz-relay/src/api/invites.rs`). A success answers 200 with a [STATUS_JOINED] or
|
||||||
|
* [STATUS_ALREADY_MEMBER] `status`; a refusal answers 403 with `{"error": <slug>}`.
|
||||||
|
*/
|
||||||
|
object BuzzInviteClaim {
|
||||||
|
/** 200: the claimer was added to the workspace. */
|
||||||
|
const val STATUS_JOINED = "joined"
|
||||||
|
|
||||||
|
/** 200: the claimer was already a member — nothing changed, and no use was consumed. */
|
||||||
|
const val STATUS_ALREADY_MEMBER = "already_member"
|
||||||
|
|
||||||
|
/** 403: the invite's lifetime has passed. */
|
||||||
|
const val ERROR_EXPIRED = "invite_expired"
|
||||||
|
|
||||||
|
/** 403: a `v2.` invite minted with `max_uses` has no uses left. */
|
||||||
|
const val ERROR_EXHAUSTED = "invite_exhausted"
|
||||||
|
|
||||||
|
/** 403: unknown, revoked, malformed, or minted for another workspace. */
|
||||||
|
const val ERROR_INVALID = "invite_invalid"
|
||||||
|
|
||||||
|
/** 403: the workspace has a join policy and no valid acceptance receipt was sent. */
|
||||||
|
const val ERROR_JOIN_POLICY_REQUIRED = "join_policy_required"
|
||||||
|
|
||||||
|
private val JSON = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class Body(
|
||||||
|
val status: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The `status` of a claim response body, or null when absent/unparseable. */
|
||||||
|
fun statusOf(body: String): String? = parse(body)?.status
|
||||||
|
|
||||||
|
/** The `error` slug of a claim response body, or null when absent/unparseable. */
|
||||||
|
fun errorOf(body: String): String? = parse(body)?.error
|
||||||
|
|
||||||
|
private fun parse(body: String): Body? =
|
||||||
|
try {
|
||||||
|
JSON.decodeFromString<Body>(body)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz NIP-MP project (`kind:30621`): an addressable, signed, named grouping of NIP-34
|
||||||
|
* repository announcements (`kind:30617`,
|
||||||
|
* [com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent]) referenced by
|
||||||
|
* coordinate, so one project can span repositories owned by different pubkeys. It is metadata
|
||||||
|
* only — its signer gains no authority over any member.
|
||||||
|
*
|
||||||
|
* Tags: exactly one non-empty `d` (the project slug), optional `name` (≤256 bytes; clients
|
||||||
|
* fall back to `d`), optional `description` (≤2048 bytes), 0–64 member `a` tags
|
||||||
|
* ([ProjectMember]; no two with the same coordinate), optional `buzz-channel` and
|
||||||
|
* `buzz-visibility` (`listed`|`unlisted`, anything else reads as listed). `content` is `""`
|
||||||
|
* and carries no meaning; unknown tags are ignored. Global-only: a stray `h` never scopes it.
|
||||||
|
*
|
||||||
|
* Ground truth: `build_project` / `validate_project_envelope` in Buzz's
|
||||||
|
* `buzz-sdk/src/builders.rs`, `validate_project_envelope` in
|
||||||
|
* `buzz-relay/src/handlers/ingest.rs` (mirrored by [ProjectValidator]), and
|
||||||
|
* `docs/nips/NIP-MP.md` with its `NIP-MP.fixtures.json` oracle.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class ProjectEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The project slug — the `d` tag. */
|
||||||
|
fun slug() = dTag()
|
||||||
|
|
||||||
|
/** The `name` tag, if any. */
|
||||||
|
fun name() = tags.projectName()
|
||||||
|
|
||||||
|
/** What clients display: the `name`, falling back to the slug. */
|
||||||
|
fun displayName() = name() ?: slug()
|
||||||
|
|
||||||
|
fun description() = tags.projectDescription()
|
||||||
|
|
||||||
|
/** The well-formed member repositories. */
|
||||||
|
fun members(): List<ProjectMember> = tags.projectMembers()
|
||||||
|
|
||||||
|
/** The member repositories' `30617` addresses. */
|
||||||
|
fun memberAddresses(): List<Address> = members().map { it.address }
|
||||||
|
|
||||||
|
/** The discussion channel reference — metadata, not routing. */
|
||||||
|
fun channelId() = tags.projectChannel()
|
||||||
|
|
||||||
|
fun visibility(): ProjectVisibility = tags.projectVisibility()
|
||||||
|
|
||||||
|
fun isListed() = visibility() == ProjectVisibility.LISTED
|
||||||
|
|
||||||
|
/** The relay's ingest verdict: null when accepted, else the rule that rejects it. */
|
||||||
|
fun validate(): ProjectRejection? = ProjectValidator.validate(tags)
|
||||||
|
|
||||||
|
fun isWellFormed() = validate() == null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 30621
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a project with Buzz's writer policy (`build_project`): a non-empty slug of at
|
||||||
|
* most 1024 bytes, [channelId] a UUID, empty content, and the whole envelope checked
|
||||||
|
* by [ProjectValidator] (so duplicate or oversized members and metadata are refused
|
||||||
|
* here instead of by the relay).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
fun build(
|
||||||
|
slug: String,
|
||||||
|
name: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
members: List<ProjectMember> = emptyList(),
|
||||||
|
channelId: String? = null,
|
||||||
|
visibility: ProjectVisibility? = null,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ProjectEvent>.() -> Unit = {},
|
||||||
|
): EventTemplate<ProjectEvent> {
|
||||||
|
require(slug.isNotEmpty()) { "project slug must not be empty" }
|
||||||
|
require(slug.encodeToByteArray().size <= ProjectValidator.D_MAX_BYTES) { "project slug must not exceed ${ProjectValidator.D_MAX_BYTES} bytes" }
|
||||||
|
if (channelId != null) {
|
||||||
|
require(runCatching { Uuid.parse(channelId) }.isSuccess) { "buzz-channel must be a valid UUID (got $channelId)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val template =
|
||||||
|
eventTemplate<ProjectEvent>(KIND, "", createdAt) {
|
||||||
|
dTag(slug)
|
||||||
|
name?.let { projectName(it) }
|
||||||
|
description?.let { projectDescription(it) }
|
||||||
|
projectMembers(members)
|
||||||
|
channelId?.let { projectChannel(it) }
|
||||||
|
visibility?.let { projectVisibility(it) }
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectValidator.validate(template.tags)?.let { throw IllegalArgumentException("[${it.rule.id}] ${it.message}") }
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eight NIP-MP ingest rules. [id] is the stable identifier the shared conformance
|
||||||
|
* fixtures (`docs/nips/NIP-MP.fixtures.json` in Buzz) name in `reject_rules`.
|
||||||
|
*/
|
||||||
|
enum class ProjectRule(
|
||||||
|
val id: String,
|
||||||
|
) {
|
||||||
|
D_CARDINALITY("d-cardinality"),
|
||||||
|
D_EMPTY("d-empty"),
|
||||||
|
MEMBER_CAP("member-cap"),
|
||||||
|
MEMBER_TAG_ARITY("member-tag-arity"),
|
||||||
|
MEMBER_COORDINATE_MALFORMED("member-coordinate-malformed"),
|
||||||
|
MEMBER_DUPLICATE("member-duplicate"),
|
||||||
|
METADATA_CARDINALITY("metadata-cardinality"),
|
||||||
|
METADATA_LENGTH("metadata-length"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rejected project envelope: which [rule] fired and why. */
|
||||||
|
data class ProjectRejection(
|
||||||
|
val rule: ProjectRule,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-MP envelope validation — a port of `validate_project_envelope` in Buzz's
|
||||||
|
* `buzz-relay/src/handlers/ingest.rs` (and the matching Layer-A validator in
|
||||||
|
* `buzz-sdk/src/builders.rs`), evaluated in the relay's order so the first failing rule is the
|
||||||
|
* one the relay would report. `content` is never inspected and unknown tags are ignored.
|
||||||
|
* Deliberately absent, as upstream: any membership authorization — referencing another
|
||||||
|
* owner's repository is legal and grants nothing.
|
||||||
|
*/
|
||||||
|
object ProjectValidator {
|
||||||
|
/** Maximum member `a` tags, counted over raw tags (duplicates included). */
|
||||||
|
const val MEMBER_CAP = 64
|
||||||
|
|
||||||
|
/** The relay's generic `d` bound (`D_TAG_MAX_LEN`); the SDK reports it as `d-empty`. */
|
||||||
|
const val D_MAX_BYTES = 1024
|
||||||
|
const val NAME_MAX_BYTES = 256
|
||||||
|
const val DESCRIPTION_MAX_BYTES = 2048
|
||||||
|
const val METADATA_TAG_MAX_BYTES = 256
|
||||||
|
|
||||||
|
/** Metadata tags a project may carry at most once each, in the relay's check order. */
|
||||||
|
private val SINGLETON_METADATA =
|
||||||
|
listOf(
|
||||||
|
NameTag.TAG_NAME to NAME_MAX_BYTES,
|
||||||
|
DescriptionTag.TAG_NAME to DESCRIPTION_MAX_BYTES,
|
||||||
|
ChannelTag.TAG_NAME to ChannelTag.MAX_BYTES,
|
||||||
|
VisibilityTag.TAG_NAME to VisibilityTag.MAX_BYTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Null when [tags] form a valid project envelope; otherwise the first rule that fails. */
|
||||||
|
fun validate(tags: TagArray): ProjectRejection? {
|
||||||
|
val dValues = mutableListOf<String>()
|
||||||
|
val members = mutableListOf<Array<String>>()
|
||||||
|
val singletonCounts = IntArray(SINGLETON_METADATA.size)
|
||||||
|
val singletonValues = arrayOfNulls<String>(SINGLETON_METADATA.size)
|
||||||
|
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.isEmpty()) continue
|
||||||
|
val value = tag.getOrNull(1) ?: ""
|
||||||
|
when (val name = tag[0]) {
|
||||||
|
DTag.TAG_NAME -> dValues.add(value)
|
||||||
|
ProjectMemberTag.TAG_NAME -> members.add(tag)
|
||||||
|
else -> {
|
||||||
|
val index = SINGLETON_METADATA.indexOfFirst { it.first == name }
|
||||||
|
if (index >= 0) {
|
||||||
|
singletonCounts[index]++
|
||||||
|
singletonValues[index] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dValues.size != 1) return reject(ProjectRule.D_CARDINALITY, "project event must have exactly one `d` tag (got ${dValues.size})")
|
||||||
|
if (dValues[0].isEmpty()) return reject(ProjectRule.D_EMPTY, "project event `d` tag must not be empty")
|
||||||
|
if (dValues[0].utf8Size() > D_MAX_BYTES) return reject(ProjectRule.D_EMPTY, "project event `d` tag exceeds $D_MAX_BYTES bytes")
|
||||||
|
|
||||||
|
if (members.size > MEMBER_CAP) return reject(ProjectRule.MEMBER_CAP, "project event must have at most $MEMBER_CAP member `a` tags (got ${members.size})")
|
||||||
|
for (member in members) {
|
||||||
|
if (member.size !in 2..3) return reject(ProjectRule.MEMBER_TAG_ARITY, "project event member `a` tag must have exactly 2 or 3 elements (got ${member.size})")
|
||||||
|
}
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
for (member in members) {
|
||||||
|
val coordinate = member[1]
|
||||||
|
if (!ProjectMemberTag.isValidCoordinate(coordinate)) {
|
||||||
|
return reject(ProjectRule.MEMBER_COORDINATE_MALFORMED, "project event member `a` tag must be `30617:<lowercase-64-hex-owner>:<repo-d>` (got \"$coordinate\")")
|
||||||
|
}
|
||||||
|
if (!seen.add(coordinate)) return reject(ProjectRule.MEMBER_DUPLICATE, "project event has duplicate member coordinate \"$coordinate\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in SINGLETON_METADATA.indices) {
|
||||||
|
if (singletonCounts[i] > 1) {
|
||||||
|
return reject(ProjectRule.METADATA_CARDINALITY, "project event must have at most one `${SINGLETON_METADATA[i].first}` tag (got ${singletonCounts[i]})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i in SINGLETON_METADATA.indices) {
|
||||||
|
val value = singletonValues[i] ?: continue
|
||||||
|
val (name, max) = SINGLETON_METADATA[i]
|
||||||
|
if (value.utf8Size() > max) return reject(ProjectRule.METADATA_LENGTH, "project event `$name` tag too long (${value.utf8Size()} bytes, max $max)")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reject(
|
||||||
|
rule: ProjectRule,
|
||||||
|
message: String,
|
||||||
|
) = ProjectRejection(rule, message)
|
||||||
|
|
||||||
|
private fun String.utf8Size() = encodeToByteArray().size
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag
|
||||||
|
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectName(name: String) = addUnique(NameTag.assemble(name))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectDescription(description: String) = addUnique(DescriptionTag.assemble(description))
|
||||||
|
|
||||||
|
/** One `a` tag per member. Duplicate coordinates are not merged — the relay rejects them. */
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectMembers(members: List<ProjectMember>) = members.forEach { add(ProjectMemberTag.assemble(it)) }
|
||||||
|
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectChannel(channelId: String) = addUnique(ChannelTag.assemble(channelId))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectVisibility(visibility: ProjectVisibility) = addUnique(VisibilityTag.assemble(visibility))
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag
|
||||||
|
|
||||||
|
/** The display name — the `name` tag. */
|
||||||
|
fun TagArray.projectName(): String? = fastFirstNotNullOfOrNull(NameTag::parse)
|
||||||
|
|
||||||
|
/** The description — the `description` tag. */
|
||||||
|
fun TagArray.projectDescription(): String? = fastFirstNotNullOfOrNull(DescriptionTag::parse)
|
||||||
|
|
||||||
|
/** The member repositories whose `a` tags are well-formed (malformed ones are skipped). */
|
||||||
|
fun TagArray.projectMembers(): List<ProjectMember> = fastMapNotNullDense(ProjectMemberTag::parse)
|
||||||
|
|
||||||
|
/** The discussion channel reference — the `buzz-channel` tag. */
|
||||||
|
fun TagArray.projectChannel(): String? = fastFirstNotNullOfOrNull(ChannelTag::parse)
|
||||||
|
|
||||||
|
/** The raw `buzz-visibility` value, uninterpreted. */
|
||||||
|
fun TagArray.projectVisibilityCode(): String? = fastFirstNotNullOfOrNull(VisibilityTag::parse)
|
||||||
|
|
||||||
|
/** The interpreted visibility: [ProjectVisibility.UNLISTED] only for exactly `unlisted`. */
|
||||||
|
fun TagArray.projectVisibility(): ProjectVisibility = ProjectVisibility.interpret(projectVisibilityCode())
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-MP `buzz-channel` tag — the UUID of the channel a project's discussion lives in.
|
||||||
|
* Metadata only: it neither routes nor scopes the event (projects are global), and a value
|
||||||
|
* the viewer cannot resolve must render as "no channel link", never as a broken one. The
|
||||||
|
* relay bounds it to [MAX_BYTES] and does not interpret it; Buzz's writer requires a UUID.
|
||||||
|
* Ground truth: `build_project` in Buzz's `buzz-sdk/src/builders.rs`.
|
||||||
|
*/
|
||||||
|
object ChannelTag {
|
||||||
|
const val TAG_NAME = "buzz-channel"
|
||||||
|
const val MAX_BYTES = 256
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(channelId: String) = arrayOf(TAG_NAME, channelId)
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects.tags
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.arrayOfNotNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One member repository of a NIP-MP project: the NIP-34 repository announcement's
|
||||||
|
* [address] (`30617:<owner>:<repo-d>`) and an optional, opaque, unauthenticated
|
||||||
|
* [relayHint]. Member identity is the coordinate alone — two tags naming one coordinate with
|
||||||
|
* different hints are duplicates.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ProjectMember(
|
||||||
|
val address: Address,
|
||||||
|
val relayHint: String? = null,
|
||||||
|
) {
|
||||||
|
/** The canonical coordinate string, the identity members are compared by. */
|
||||||
|
val coordinate: String get() = address.toValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A NIP-MP member `a` tag: `["a", "30617:<owner>:<repo-d>"]` or
|
||||||
|
* `["a", "30617:<owner>:<repo-d>", "<relay-url>"]`.
|
||||||
|
*
|
||||||
|
* The coordinate grammar is stricter than a generic NIP-01 address: the kind is the literal
|
||||||
|
* `30617` (a repository *announcement*, never `30618` state), the owner is exactly 64
|
||||||
|
* **lowercase** hex characters (`#a` matching is byte-exact, so an uppercase owner would be
|
||||||
|
* invisible to readers), and the repo `d` is non-empty and taken verbatim — the value splits
|
||||||
|
* on the first two colons only, so a repo `d` containing `:` stays addressable. The relay
|
||||||
|
* hint is never parsed or validated by content. Ground truth:
|
||||||
|
* `parse_project_member_coordinate` in Buzz's `buzz-relay/src/handlers/ingest.rs` and
|
||||||
|
* `ProjectMemberCoord` in `buzz-sdk/src/builders.rs`.
|
||||||
|
*/
|
||||||
|
object ProjectMemberTag {
|
||||||
|
const val TAG_NAME = "a"
|
||||||
|
const val MEMBER_KIND = GitRepositoryEvent.KIND
|
||||||
|
private const val MEMBER_KIND_SEGMENT = "30617"
|
||||||
|
|
||||||
|
/** Parses a strict member coordinate, or null when it is malformed. */
|
||||||
|
fun parseCoordinate(coordinate: String): Address? {
|
||||||
|
val parts = coordinate.split(':', limit = 3)
|
||||||
|
if (parts.size != 3) return null
|
||||||
|
val (kind, owner, repoD) = parts
|
||||||
|
if (kind != MEMBER_KIND_SEGMENT) return null
|
||||||
|
if (!isLowercaseHex64(owner)) return null
|
||||||
|
if (repoD.isEmpty()) return null
|
||||||
|
return Address(MEMBER_KIND, owner, repoD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidCoordinate(coordinate: String) = parseCoordinate(coordinate) != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a member tag with the arity (two or three elements) and coordinate rules the
|
||||||
|
* relay enforces; null for anything else.
|
||||||
|
*/
|
||||||
|
fun parse(tag: Tag): ProjectMember? {
|
||||||
|
if (tag.size !in 2..3 || tag[0] != TAG_NAME) return null
|
||||||
|
val address = parseCoordinate(tag[1]) ?: return null
|
||||||
|
return ProjectMember(address, tag.getOrNull(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(
|
||||||
|
coordinate: String,
|
||||||
|
relayHint: String? = null,
|
||||||
|
) = arrayOfNotNull(TAG_NAME, coordinate, relayHint)
|
||||||
|
|
||||||
|
fun assemble(member: ProjectMember) = assemble(member.coordinate, member.relayHint)
|
||||||
|
|
||||||
|
fun assemble(
|
||||||
|
owner: HexKey,
|
||||||
|
repoD: String,
|
||||||
|
relayHint: String? = null,
|
||||||
|
) = assemble("$MEMBER_KIND_SEGMENT:$owner:$repoD", relayHint)
|
||||||
|
|
||||||
|
private fun isLowercaseHex64(value: String): Boolean {
|
||||||
|
if (value.length != 64) return false
|
||||||
|
for (c in value) {
|
||||||
|
if (c !in '0'..'9' && c !in 'a'..'f') return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects.tags
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A NIP-MP project's listing visibility. Absent or unrecognized values read as [LISTED]: a
|
||||||
|
* typo in a metadata field is not a privacy signal, so it must never hide a project.
|
||||||
|
*/
|
||||||
|
enum class ProjectVisibility(
|
||||||
|
val code: String,
|
||||||
|
) {
|
||||||
|
LISTED("listed"),
|
||||||
|
UNLISTED("unlisted"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The writer-side parse: only the two defined values. */
|
||||||
|
fun parseStrict(code: String): ProjectVisibility? =
|
||||||
|
when (code) {
|
||||||
|
LISTED.code -> LISTED
|
||||||
|
UNLISTED.code -> UNLISTED
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reader-side interpretation: anything but `unlisted` is [LISTED]. */
|
||||||
|
fun interpret(code: String?): ProjectVisibility = if (code == UNLISTED.code) UNLISTED else LISTED
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-MP `buzz-visibility` tag — `listed` (the default) or `unlisted`. The relay bounds
|
||||||
|
* it to [MAX_BYTES] and does not interpret it; readers apply [ProjectVisibility.interpret].
|
||||||
|
*/
|
||||||
|
object VisibilityTag {
|
||||||
|
const val TAG_NAME = "buzz-visibility"
|
||||||
|
const val MAX_BYTES = 256
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
/** The raw value, uninterpreted. */
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(visibility: ProjectVisibility) = arrayOf(TAG_NAME, visibility.code)
|
||||||
|
}
|
||||||
+78
@@ -95,5 +95,83 @@ data class AttestationConditions(
|
|||||||
|
|
||||||
/** True when [conditions] is a syntactically valid canonical conditions string. */
|
/** True when [conditions] is a syntactically valid canonical conditions string. */
|
||||||
fun isValid(conditions: String): Boolean = parse(conditions) != null
|
fun isValid(conditions: String): Boolean = parse(conditions) != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an authentication event signed at [authCreatedAt] satisfies every time clause
|
||||||
|
* in [conditions]. Both operators are strict (equality satisfies neither) and every clause
|
||||||
|
* must pass, including repeated ones — so this walks the raw string rather than [parse],
|
||||||
|
* which keeps only the last clause of each kind. `kind=` is deliberately ignored: Buzz
|
||||||
|
* treats the credential as connection-wide at admission. Ground truth:
|
||||||
|
* `verify_auth_tag_for_auth_event` in `buzz-sdk/src/nip_oa.rs`, enforced by the relay on
|
||||||
|
* NIP-42 AUTH, NIP-98 HTTP, Git and media admission.
|
||||||
|
*/
|
||||||
|
fun timeBoundsAllow(
|
||||||
|
conditions: String,
|
||||||
|
authCreatedAt: Long,
|
||||||
|
): Boolean {
|
||||||
|
if (conditions.isEmpty()) return true
|
||||||
|
for (clause in conditions.split("&")) {
|
||||||
|
when {
|
||||||
|
clause.startsWith("created_at<") -> {
|
||||||
|
val bound = clause.removePrefix("created_at<").toLongOrNull() ?: return false
|
||||||
|
if (authCreatedAt >= bound) return false
|
||||||
|
}
|
||||||
|
clause.startsWith("created_at>") -> {
|
||||||
|
val bound = clause.removePrefix("created_at>").toLongOrNull() ?: return false
|
||||||
|
if (authCreatedAt <= bound) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when every clause in [conditions] applies to an event of [kind] signed at
|
||||||
|
* [createdAt]: `kind=` must match and both time bounds hold (strictly). Unlike
|
||||||
|
* [timeBoundsAllow], `kind=` counts - this is the check for an attestation carried on an
|
||||||
|
* event (e.g. an agent's kind-0 profile), not for connection admission. Any clause it
|
||||||
|
* does not recognize fails. Mirrors `profile_valid_oa_owner_pubkey` in Buzz's
|
||||||
|
* `desktop/src-tauri/src/nostr_convert.rs`.
|
||||||
|
*/
|
||||||
|
fun appliesToEvent(
|
||||||
|
conditions: String,
|
||||||
|
kind: Int,
|
||||||
|
createdAt: Long,
|
||||||
|
): Boolean {
|
||||||
|
if (conditions.isEmpty()) return true
|
||||||
|
for (clause in conditions.split("&")) {
|
||||||
|
val ok =
|
||||||
|
when {
|
||||||
|
clause.startsWith("kind=") -> clause.removePrefix("kind=").toIntOrNull() == kind
|
||||||
|
clause.startsWith("created_at<") -> clause.removePrefix("created_at<").toLongOrNull()?.let { createdAt < it } ?: false
|
||||||
|
clause.startsWith("created_at>") -> clause.removePrefix("created_at>").toLongOrNull()?.let { createdAt > it } ?: false
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (!ok) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last second at which [conditions] still admit an authentication (the tightest
|
||||||
|
* `created_at<` bound minus one), or null when there is no upper bound.
|
||||||
|
*/
|
||||||
|
fun validUntil(conditions: String): Long? =
|
||||||
|
conditions
|
||||||
|
.split("&")
|
||||||
|
.mapNotNull { if (it.startsWith("created_at<")) it.removePrefix("created_at<").toLongOrNull() else null }
|
||||||
|
.minOrNull()
|
||||||
|
?.let { it - 1 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first second at which [conditions] admit an authentication (the tightest
|
||||||
|
* `created_at>` bound plus one), or null when there is no lower bound.
|
||||||
|
*/
|
||||||
|
fun validFrom(conditions: String): Long? =
|
||||||
|
conditions
|
||||||
|
.split("&")
|
||||||
|
.mapNotNull { if (it.startsWith("created_at>")) it.removePrefix("created_at>").toLongOrNull() else null }
|
||||||
|
.maxOrNull()
|
||||||
|
?.let { it + 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-5
@@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.core.isValid
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||||
import com.vitorpamplona.quartz.utils.Hex
|
|
||||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,26 +64,54 @@ data class OwnerAttestation(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies this attestation authorizes [agentPubKey]. Checks structural validity
|
* Verifies this attestation authorizes [agentPubKey]. Checks structural validity
|
||||||
* (valid hex keys, canonical conditions, no self-attestation) and then the owner's
|
* (lowercase hex keys and signature, canonical conditions, no self-attestation) and then
|
||||||
* Schnorr signature over the commitment hash.
|
* the owner's Schnorr signature over the commitment hash. Time clauses are NOT evaluated
|
||||||
|
* here; see [verifyForAuthAt].
|
||||||
*/
|
*/
|
||||||
fun verify(agentPubKey: HexKey): Boolean {
|
fun verify(agentPubKey: HexKey): Boolean {
|
||||||
if (!agentPubKey.isValid() || !ownerPubKey.isValid()) return false
|
if (!agentPubKey.isValid() || !ownerPubKey.isValid() || !isLowercaseHex(ownerPubKey, 64)) return false
|
||||||
// Self-attestation is explicitly prohibited: the owner must differ from the agent.
|
// Self-attestation is explicitly prohibited: the owner must differ from the agent.
|
||||||
if (ownerPubKey == agentPubKey) return false
|
if (ownerPubKey == agentPubKey) return false
|
||||||
if (!AttestationConditions.isValid(conditions)) return false
|
if (!AttestationConditions.isValid(conditions)) return false
|
||||||
if (sig.length != 128 || !Hex.isHex(sig)) return false
|
// Buzz rejects uppercase hex before it reaches its (permissive) decoder.
|
||||||
|
if (!isLowercaseHex(sig, 128)) return false
|
||||||
|
|
||||||
val message = sha256(commitment(agentPubKey).encodeToByteArray())
|
val message = sha256(commitment(agentPubKey).encodeToByteArray())
|
||||||
return Nip01Crypto.verify(sig.hexToByteArray(), message, ownerPubKey.hexToByteArray())
|
return Nip01Crypto.verify(sig.hexToByteArray(), message, ownerPubKey.hexToByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The check Buzz runs at admission: [verify], plus every `created_at<` / `created_at>`
|
||||||
|
* clause evaluated against the signed authentication event's [authCreatedAt] (strictly).
|
||||||
|
* An expired or not-yet-valid credential grants nothing, so the relay treats the agent as a
|
||||||
|
* non-member (`restricted`).
|
||||||
|
*/
|
||||||
|
fun verifyForAuthAt(
|
||||||
|
agentPubKey: HexKey,
|
||||||
|
authCreatedAt: Long,
|
||||||
|
): Boolean = verify(agentPubKey) && isValidAt(authCreatedAt)
|
||||||
|
|
||||||
|
/** True when the time clauses admit an authentication signed at [authCreatedAt]. */
|
||||||
|
fun isValidAt(authCreatedAt: Long): Boolean = AttestationConditions.timeBoundsAllow(conditions, authCreatedAt)
|
||||||
|
|
||||||
|
/** The last second this credential admits an authentication, or null when unbounded. */
|
||||||
|
fun validUntil(): Long? = AttestationConditions.validUntil(conditions)
|
||||||
|
|
||||||
|
/** The first second this credential admits an authentication, or null when unbounded. */
|
||||||
|
fun validFrom(): Long? = AttestationConditions.validFrom(conditions)
|
||||||
|
|
||||||
/** Builds the NIP-OA `auth` tag for this attestation. */
|
/** Builds the NIP-OA `auth` tag for this attestation. */
|
||||||
fun toTag(): Array<String> = AuthTag.assemble(this)
|
fun toTag(): Array<String> = AuthTag.assemble(this)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val COMMITMENT_PREFIX = "nostr:agent-auth:"
|
const val COMMITMENT_PREFIX = "nostr:agent-auth:"
|
||||||
|
|
||||||
|
/** Exactly [length] characters of lowercase hex, the only form Buzz accepts on the wire. */
|
||||||
|
fun isLowercaseHex(
|
||||||
|
value: String,
|
||||||
|
length: Int,
|
||||||
|
): Boolean = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }
|
||||||
|
|
||||||
/** SHA-256 of the commitment string — the 32-byte message the owner signs. */
|
/** SHA-256 of the commitment string — the 32-byte message the owner signs. */
|
||||||
fun commitmentHash(
|
fun commitmentHash(
|
||||||
agentPubKey: HexKey,
|
agentPubKey: HexKey,
|
||||||
@@ -119,6 +146,35 @@ data class OwnerAttestation(
|
|||||||
return sign(agentPubKey, conditions.encode(), priv)
|
return sign(agentPubKey, conditions.encode(), priv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner an agent's event declares through NIP-OA, or null. The event must carry
|
||||||
|
* **exactly one** `auth` tag (a malformed second one still counts, so there is no
|
||||||
|
* first-valid-tag fallback), it must verify for the event's own author [pubKey], and every
|
||||||
|
* condition must apply to this event ([AttestationConditions.appliesToEvent]).
|
||||||
|
*
|
||||||
|
* Buzz reads an agent's owner this way off the agent's kind-0 profile: that owner may edit
|
||||||
|
* the agent's channel messages, and names the agent in the UI. The event's own signature
|
||||||
|
* is the caller's to have checked. Ground truth: `profile_valid_oa_owner_pubkey` in Buzz's
|
||||||
|
* `desktop/src-tauri/src/nostr_convert.rs`.
|
||||||
|
*/
|
||||||
|
fun verifiedOwnerOf(
|
||||||
|
pubKey: HexKey,
|
||||||
|
kind: Int,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
): HexKey? {
|
||||||
|
var authTag: Array<String>? = null
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.isEmpty() || tag[0] != AuthTag.TAG_NAME) continue
|
||||||
|
if (authTag != null) return null
|
||||||
|
authTag = tag
|
||||||
|
}
|
||||||
|
val attestation = AuthTag.parse(authTag ?: return null) ?: return null
|
||||||
|
if (!attestation.verify(pubKey)) return null
|
||||||
|
if (!AttestationConditions.appliesToEvent(attestation.conditions, kind, createdAt)) return null
|
||||||
|
return attestation.ownerPubKey
|
||||||
|
}
|
||||||
|
|
||||||
/** Parses a NIP-OA `auth` tag; see [AuthTag.parse]. Does not verify the signature. */
|
/** Parses a NIP-OA `auth` tag; see [AuthTag.parse]. Does not verify the signature. */
|
||||||
fun parse(tag: Tag): OwnerAttestation? = AuthTag.parse(tag)
|
fun parse(tag: Tag): OwnerAttestation? = AuthTag.parse(tag)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
|||||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
import com.vitorpamplona.quartz.utils.Hex
|
|
||||||
import com.vitorpamplona.quartz.utils.ensure
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,21 +42,22 @@ object AuthTag {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a well-formed `auth` tag into an [OwnerAttestation]. Returns null on any
|
* Parses a well-formed `auth` tag into an [OwnerAttestation]. Returns null on any
|
||||||
* structural problem: wrong name/arity, an owner pubkey that is not 64-char hex, a
|
* structural problem: wrong name or not exactly 4 elements, an owner pubkey that is not
|
||||||
* signature that is not 128-char hex, or non-canonical conditions. This is a
|
* 64-char lowercase hex, a signature that is not 128-char lowercase hex, or non-canonical
|
||||||
|
* conditions (mirrors `parse_auth_tag_fields` in Buzz's `buzz-sdk/src/nip_oa.rs`). This is a
|
||||||
* *shape* check only — it does not verify the signature (call
|
* *shape* check only — it does not verify the signature (call
|
||||||
* [OwnerAttestation.verify]).
|
* [OwnerAttestation.verify]).
|
||||||
*/
|
*/
|
||||||
fun parse(tag: Array<String>): OwnerAttestation? {
|
fun parse(tag: Array<String>): OwnerAttestation? {
|
||||||
ensure(tag.has(3)) { return null }
|
ensure(tag.size == 4) { return null }
|
||||||
ensure(tag[0] == TAG_NAME) { return null }
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
|
||||||
val owner = tag[1]
|
val owner = tag[1]
|
||||||
val conditions = tag[2]
|
val conditions = tag[2]
|
||||||
val sig = tag[3]
|
val sig = tag[3]
|
||||||
|
|
||||||
ensure(Hex.isHex64(owner)) { return null }
|
ensure(OwnerAttestation.isLowercaseHex(owner, 64)) { return null }
|
||||||
ensure(sig.length == 128 && Hex.isHex(sig)) { return null }
|
ensure(OwnerAttestation.isLowercaseHex(sig, 128)) { return null }
|
||||||
ensure(AttestationConditions.isValid(conditions)) { return null }
|
ensure(AttestationConditions.isValid(conditions)) { return null }
|
||||||
|
|
||||||
return OwnerAttestation(owner, conditions, sig)
|
return OwnerAttestation(owner, conditions, sig)
|
||||||
|
|||||||
+6
-4
@@ -67,13 +67,15 @@ open class BuzzMembershipPolicy(
|
|||||||
/**
|
/**
|
||||||
* Runs after the NIP-42 proof checks out (see [FullAuthPolicy.authorize]). If the auth event
|
* Runs after the NIP-42 proof checks out (see [FullAuthPolicy.authorize]). If the auth event
|
||||||
* carries an owner-signed attestation authorizing this agent, and the owner is a member,
|
* carries an owner-signed attestation authorizing this agent, and the owner is a member,
|
||||||
* remember the agent as a member for this connection. We never throw here — a missing or
|
* remember the agent as a member for this connection. Like Buzz, the credential's
|
||||||
* invalid attestation just means the key is authenticated but not (yet) authorized, which the
|
* `created_at<` / `created_at>` clauses are checked against the AUTH event's own `created_at`,
|
||||||
* membership gate below handles.
|
* so an expired or not-yet-valid attestation grants nothing. We never throw here — a missing
|
||||||
|
* or invalid attestation just means the key is authenticated but not (yet) authorized, which
|
||||||
|
* the membership gate below handles.
|
||||||
*/
|
*/
|
||||||
override suspend fun authorize(event: RelayAuthEvent) {
|
override suspend fun authorize(event: RelayAuthEvent) {
|
||||||
val attestation = event.tags.firstNotNullOfOrNull(AuthTag::parse) ?: return
|
val attestation = event.tags.firstNotNullOfOrNull(AuthTag::parse) ?: return
|
||||||
if (attestation.ownerPubKey in members && attestation.verify(event.pubKey)) {
|
if (attestation.ownerPubKey in members && attestation.verifyForAuthAt(event.pubKey, event.createdAt)) {
|
||||||
authorizedAgents.add(event.pubKey)
|
authorizedAgents.add(event.pubKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-5
@@ -22,10 +22,12 @@ package com.vitorpamplona.quartz.buzz.rsReadState
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
||||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +38,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
* `kind:30078` [com.vitorpamplona.quartz.nip01Core.core.Event] subclass — a second class on the
|
* `kind:30078` [com.vitorpamplona.quartz.nip01Core.core.Event] subclass — a second class on the
|
||||||
* same kind would break [com.vitorpamplona.quartz.utils.EventFactory] dispatch. Instead it is a
|
* same kind would break [com.vitorpamplona.quartz.utils.EventFactory] dispatch. Instead it is a
|
||||||
* thin content/tag layer *on top of* [AppSpecificDataEvent]:
|
* thin content/tag layer *on top of* [AppSpecificDataEvent]:
|
||||||
* - the `d` tag is `read-state:<slot-id>` ([dTagFor] / [slotIdFrom]),
|
* - the `d` tag is `read-state:<slot-id>` ([dTagFor] / [slotIdFrom]), where `<slot-id>` is exactly
|
||||||
|
* 32 lowercase hex characters ([isValidSlotId]) — any other shape MUST be ignored, since the
|
||||||
|
* fixed shape is what lets a relay recognize a read-state coordinate structurally,
|
||||||
* - a single `["t", "read-state"]` tag enables relay-side filtering,
|
* - a single `["t", "read-state"]` tag enables relay-side filtering,
|
||||||
* - `content` is a NIP-44 self-encrypted [ReadStateContent] (conversation key
|
* - `content` is a NIP-44 self-encrypted [ReadStateContent] (conversation key
|
||||||
* `nip44(user_privkey, user_pubkey)` — the user's own key on both sides).
|
* `nip44(user_privkey, user_pubkey)` — the user's own key on both sides).
|
||||||
@@ -47,18 +51,36 @@ object ReadState {
|
|||||||
const val D_TAG_PREFIX = "read-state:"
|
const val D_TAG_PREFIX = "read-state:"
|
||||||
const val T_TAG_VALUE = "read-state"
|
const val T_TAG_VALUE = "read-state"
|
||||||
|
|
||||||
|
/** A `<slot-id>` is exactly this many lowercase hex characters (16 random bytes). */
|
||||||
|
const val SLOT_ID_LENGTH = 32
|
||||||
|
|
||||||
|
/** True when [slotId] is exactly 32 lowercase hex characters (`[0-9a-f]{32}`). */
|
||||||
|
fun isValidSlotId(slotId: String): Boolean = slotId.length == SLOT_ID_LENGTH && slotId.all { it in '0'..'9' || it in 'a'..'f' }
|
||||||
|
|
||||||
|
/** A fresh random `<slot-id>`: 16 secure random bytes as lowercase hex. */
|
||||||
|
fun newSlotId(): String = RandomInstance.bytes(SLOT_ID_LENGTH / 2).toHexKey()
|
||||||
|
|
||||||
/** The addressable `d`-tag value for a given slot id. */
|
/** The addressable `d`-tag value for a given slot id. */
|
||||||
fun dTagFor(slotId: String): String = "$D_TAG_PREFIX$slotId"
|
fun dTagFor(slotId: String): String = "$D_TAG_PREFIX$slotId"
|
||||||
|
|
||||||
/** Extracts the `<slot-id>` from a `read-state:<slot-id>` d-tag value, or `null` if it does not match. */
|
/**
|
||||||
fun slotIdFrom(dTagValue: String): String? = dTagValue.removePrefix(D_TAG_PREFIX).takeIf { it != dTagValue && it.isNotEmpty() }
|
* Extracts the `<slot-id>` from a `read-state:<slot-id>` d-tag value, or `null` if it does not
|
||||||
|
* match — including a slot id that is not exactly 32 lowercase hex characters.
|
||||||
|
*/
|
||||||
|
fun slotIdFrom(dTagValue: String): String? {
|
||||||
|
if (!dTagValue.startsWith(D_TAG_PREFIX)) return null
|
||||||
|
return dTagValue.substring(D_TAG_PREFIX.length).takeIf(::isValidSlotId)
|
||||||
|
}
|
||||||
|
|
||||||
/** True if [tags] are a well-formed NIP-RS coordinate: exactly one `read-state:` d-tag and one `["t","read-state"]`. */
|
/**
|
||||||
|
* True if [tags] are a well-formed NIP-RS coordinate: exactly one `d` tag, holding
|
||||||
|
* `read-state:<32 lowercase hex>`, and exactly one `["t","read-state"]`.
|
||||||
|
*/
|
||||||
fun isReadState(tags: TagArray): Boolean {
|
fun isReadState(tags: TagArray): Boolean {
|
||||||
val dTags = tags.count { it.size > 1 && it[0] == "d" }
|
val dTags = tags.count { it.size > 1 && it[0] == "d" }
|
||||||
val readStateT = tags.count { it.size > 1 && it[0] == HashtagTag.TAG_NAME && it[1] == T_TAG_VALUE }
|
val readStateT = tags.count { it.size > 1 && it[0] == HashtagTag.TAG_NAME && it[1] == T_TAG_VALUE }
|
||||||
val dValue = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.getOrNull(1)
|
val dValue = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.getOrNull(1)
|
||||||
return dTags == 1 && readStateT == 1 && dValue != null && dValue.startsWith(D_TAG_PREFIX)
|
return dTags == 1 && readStateT == 1 && dValue != null && slotIdFrom(dValue) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds the NIP-RS `["t", "read-state"]` filter tag to an [AppSpecificDataEvent] builder. */
|
/** Adds the NIP-RS `["t", "read-state"]` filter tag to an [AppSpecificDataEvent] builder. */
|
||||||
@@ -74,6 +96,7 @@ object ReadState {
|
|||||||
signer: NostrSigner,
|
signer: NostrSigner,
|
||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
): AppSpecificDataEvent {
|
): AppSpecificDataEvent {
|
||||||
|
require(isValidSlotId(slotId)) { "NIP-RS slot id must be 32 lowercase hex characters" }
|
||||||
val ciphertext = signer.nip44Encrypt(content.encodeToJson(), signer.pubKey)
|
val ciphertext = signer.nip44Encrypt(content.encodeToJson(), signer.pubKey)
|
||||||
return signer.sign(
|
return signer.sign(
|
||||||
AppSpecificDataEvent.build(dTagFor(slotId), ciphertext, createdAt) {
|
AppSpecificDataEvent.build(dTagFor(slotId), ciphertext, createdAt) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user