Merge upstream with local changes.

This commit is contained in:
KotlinGeekDev
2025-01-01 13:29:00 +01:00
321 changed files with 16341 additions and 7355 deletions
+2 -2
View File
@@ -59,7 +59,7 @@ dependencies {
// LibSodium for ChaCha encryption (NIP-44)
// Wait for @aar support in version catalogs
implementation "com.goterl:lazysodium-android:5.1.0@aar"
implementation 'net.java.dev.jna:jna:5.14.0@aar'
implementation 'net.java.dev.jna:jna:5.15.0@aar'
//implementation (libs.lazysodium.android) { artifact { type = "aar" } }
//implementation (libs.jna) { artifact { type = "aar" } }
@@ -73,7 +73,7 @@ dependencies {
// Parses URLs from Text:
api libs.url.detector
// Parses URLs from Text:
// Normalizes URLs
api libs.rfc3986.normalizer
testImplementation libs.junit
Binary file not shown.
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2024 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.crypto.nip17
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.vitorpamplona.quartz.crypto.CryptoUtils.decrypt
import com.vitorpamplona.quartz.encoders.hexToByteArray
import junit.framework.TestCase.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AESGCMTest {
val decryptionNonce = "01e77c94bd5aba3e3cbb69594e7ba07c"
val decryptionKey = "c128ecffab90ee7810e3df08e7fb2cc39a8d40f24201f48b2b36e23b34ac50ee"
val cipher = AESGCM(decryptionKey.hexToByteArray(), decryptionNonce.toByteArray(Charsets.UTF_8))
@Test
fun encryptDecrypt() {
val encrypted = cipher.encrypt("Testing".toByteArray(Charsets.UTF_8))
val decrypted = cipher.decrypt(encrypted)
assertEquals("Testing", String(decrypted))
}
@Test
fun imageTest() {
val image =
getInstrumentation().context.assets.open("ovxxk2vz.jpg").use {
it.readAllBytes()
}
val decrypted = cipher.decrypt(image)
assertEquals(44201, decrypted.size)
}
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.encoders
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.crypto.CryptoUtils
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@@ -69,4 +71,43 @@ class HexEncodingTest {
)
}
}
@Test
fun testIsHex() {
assertFalse("/0", HexValidator.isHex("/0"))
assertFalse("/.", HexValidator.isHex("/."))
assertFalse("!!", HexValidator.isHex("!!"))
assertFalse("::", HexValidator.isHex("::"))
assertFalse("@@", HexValidator.isHex("@@"))
assertFalse("GG", HexValidator.isHex("GG"))
assertFalse("FG", HexValidator.isHex("FG"))
assertFalse("`a", HexValidator.isHex("`a"))
assertFalse("gg", HexValidator.isHex("gg"))
assertFalse("fg", HexValidator.isHex("fg"))
}
@OptIn(ExperimentalStdlibApi::class)
@Test
fun testRandomsIsHex() {
for (i in 0..10000) {
val bytes = CryptoUtils.privkeyCreate()
val hex = bytes.toHexString(HexFormat.Default)
assertTrue(hex, HexValidator.isHex(hex))
val hexUpper = bytes.toHexString(HexFormat.UpperCase)
assertTrue(hexUpper, HexValidator.isHex(hexUpper))
}
}
@OptIn(ExperimentalStdlibApi::class)
@Test
fun testRandomsUppercase() {
for (i in 0..1000) {
val bytes = CryptoUtils.privkeyCreate()
val hex = bytes.toHexString(HexFormat.UpperCase)
assertEquals(
bytes.toList(),
Hex.decode(hex).toList(),
)
}
}
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2024 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.crypto.nip17
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
interface NostrCipher {
fun name(): String
fun encrypt(bytesToEncrypt: ByteArray): ByteArray
fun decrypt(bytesToDecrypt: ByteArray): ByteArray
}
class AESGCM(
val keyBytes: ByteArray = CryptoUtils.random(32),
val nonce: ByteArray = CryptoUtils.random(16),
) : NostrCipher {
private fun newCipher() = Cipher.getInstance("AES/GCM/NoPadding")
private fun keySpec() = SecretKeySpec(keyBytes, "AES")
private fun param() = GCMParameterSpec(128, nonce)
override fun name() = NAME
fun copyUsingUTF8Nonce(): AESGCM =
AESGCM(
keyBytes,
nonce.toHexKey().toByteArray(Charsets.UTF_8),
)
override fun encrypt(bytesToEncrypt: ByteArray): ByteArray =
with(newCipher()) {
init(Cipher.ENCRYPT_MODE, keySpec(), param())
doFinal(bytesToEncrypt)
}
override fun decrypt(bytesToDecrypt: ByteArray): ByteArray =
with(newCipher()) {
init(Cipher.DECRYPT_MODE, keySpec(), param())
doFinal(bytesToDecrypt)
}
companion object {
const val NAME = "aes-gcm"
}
}
@@ -24,14 +24,25 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers
@Immutable
data class ATag(
val kind: Int,
val pubKeyHex: String,
val dTag: String,
val relay: String?,
) {
var relay: String? = null
constructor(
kind: Int,
pubKeyHex: String,
dTag: String,
relayHint: String?,
) : this(kind, pubKeyHex, dTag) {
this.relay = relayHint
}
fun countMemory(): Long =
5 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit)
8L + // kind
@@ -41,11 +52,15 @@ data class ATag(
fun toTag() = assembleATag(kind, pubKeyHex, dTag)
fun toNAddr(): String =
fun toATagArray() = removeTrailingNullsAndEmptyOthers("a", toTag(), relay)
fun toQTagArray() = removeTrailingNullsAndEmptyOthers("q", toTag(), relay)
fun toNAddr(overrideRelay: String? = relay): String =
TlvBuilder()
.apply {
addString(Nip19Bech32.TlvTypes.SPECIAL, dTag)
addStringIfNotNull(Nip19Bech32.TlvTypes.RELAY, relay)
addStringIfNotNull(Nip19Bech32.TlvTypes.RELAY, overrideRelay ?: relay)
addHex(Nip19Bech32.TlvTypes.AUTHOR, pubKeyHex)
addInt(Nip19Bech32.TlvTypes.KIND, kind)
}.build()
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2024 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.encoders
class Dimension(
val width: Int,
val height: Int,
) {
fun aspectRatio() = width.toFloat() / height.toFloat()
fun hasSize() = width > 0 && height > 0
override fun toString() = "${width}x$height"
companion object {
fun parse(dim: String): Dimension? {
if (dim == "0x0") return null
val parts = dim.split("x")
if (parts.size != 2) return null
return try {
val width = parts[0].toInt()
val height = parts[1].toInt()
Dimension(width, height)
} catch (e: Exception) {
null
}
}
}
}
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2024 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.encoders
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers
@Immutable
data class ETag(
val eventId: HexKey,
) {
var relay: String? = null
var authorPubKeyHex: HexKey? = null
constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) {
this.relay = relayHint
this.authorPubKeyHex = authorPubKeyHex
}
fun countMemory(): Long =
2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit)
eventId.bytesUsedInMemory() +
(relay?.bytesUsedInMemory() ?: 0)
fun toNEvent(): String = Nip19Bech32.createNEvent(eventId, authorPubKeyHex, null, relay)
fun toETagArray() = removeTrailingNullsAndEmptyOthers("e", eventId, relay, authorPubKeyHex)
fun toQTagArray() = removeTrailingNullsAndEmptyOthers("q", eventId, relay, authorPubKeyHex)
companion object {
fun parseNIP19(nevent: String): ETag? {
try {
val parsed = Nip19Bech32.uriToRoute(nevent)?.entity
return when (parsed) {
is Nip19Bech32.Note -> ETag(parsed.hex)
is Nip19Bech32.NEvent -> ETag(parsed.hex, parsed.author, parsed.relay.firstOrNull())
else -> null
}
} catch (e: Throwable) {
Log.w("PTag", "Issue trying to Decode NIP19 $this: ${e.message}")
return null
}
}
}
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2024 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.encoders
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
data class EventHint<T : Event>(
val event: T,
) {
var relay: String? = null
constructor(event: T, relayHint: String? = null) : this(event) {
this.relay = relayHint
}
fun countMemory(): Long =
2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit)
event.countMemory() +
(relay?.bytesUsedInMemory() ?: 0)
fun toNEvent(): String = Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay)
fun toNPub(): String = Nip19Bech32.createNPub(event.id)
fun toTagArray(tag: String) = listOfNotNull(tag, event.id, relay, event.pubKey).toTypedArray()
fun toETagArray() = toTagArray("e")
fun toQTagArray() = toTagArray("q")
}
@@ -27,65 +27,53 @@ fun ByteArray.toHexKey(): HexKey = Hex.encode(this)
fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this)
object HexValidator {
private fun isHexChar(c: Char): Boolean =
when (c) {
in '0'..'9' -> true
in 'a'..'f' -> true
in 'A'..'F' -> true
else -> false
}
val lowerCaseHex = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
val upperCaseHex = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F')
val hexToByte: IntArray =
IntArray(256) { -1 }.apply {
lowerCaseHex.forEachIndexed { index, char -> this[char.code] = index }
upperCaseHex.forEachIndexed { index, char -> this[char.code] = index }
}
// Encodes both chars in a single Int variable
val byteToHex =
IntArray(256) {
(lowerCaseHex[(it shr 4)].code shl 8) or lowerCaseHex[(it and 0xF)].code
}
object HexValidator {
fun isHex(hex: String?): Boolean {
if (hex == null) return false
if (hex.isEmpty()) return false
if (hex.length % 2 != 0) return false // must be even
var isHex = true
if (hex.length and 1 != 0) return false // must be even
for (c in hex) {
if (!isHexChar(c)) {
isHex = false
break
}
for (c in hex.indices) {
if (hexToByte[hex[c].code] < 0) return false
}
return isHex
return true
}
}
object Hex {
val hexCode =
arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
// Faster if no calculations are needed.
private fun hexToBin(ch: Char): Int =
when (ch) {
in '0'..'9' -> ch - '0'
in 'a'..'f' -> ch - 'a' + 10
in 'A'..'F' -> ch - 'A' + 10
else -> throw IllegalArgumentException("illegal hex character: $ch")
}
@JvmStatic
fun decode(hex: String): ByteArray {
// faster version of hex decoder
require(hex.length % 2 == 0)
val outSize = hex.length / 2
val out = ByteArray(outSize)
for (i in 0 until outSize) {
out[i] = (hexToBin(hex[2 * i]) * 16 + hexToBin(hex[2 * i + 1])).toByte()
require(hex.length and 1 == 0)
return ByteArray(hex.length / 2) {
(hexToByte[hex[2 * it].code] shl 4 or hexToByte[hex[2 * it + 1].code]).toByte()
}
return out
}
@JvmStatic
fun encode(input: ByteArray): String {
val len = input.size
val out = CharArray(len * 2)
for (i in 0 until len) {
out[i * 2] = hexCode[(input[i].toInt() shr 4) and 0xF]
out[i * 2 + 1] = hexCode[input[i].toInt() and 0xF]
val out = CharArray(input.size * 2)
var outIdx = 0
for (i in 0 until input.size) {
val chars = byteToHex[input[i].toInt() and 0xFF]
out[outIdx++] = (chars shr 8).toChar()
out[outIdx++] = (chars and 0xFF).toChar()
}
return String(out)
}
@@ -270,6 +270,11 @@ object Nip19Bech32 {
}.build()
.toNEvent()
@Deprecated("Use nevent instead")
fun createNote(eventId: HexKey): String = eventId.hexToByteArray().toNote()
fun createNPub(authorPubKeyHex: HexKey): String = authorPubKeyHex.hexToByteArray().toNpub()
fun createNProfile(
authorPubKeyHex: String,
relay: List<String>,
@@ -299,6 +304,7 @@ fun ByteArray.toNsec() = Bech32.encodeBytes(hrp = "nsec", this, Bech32.Encoding.
fun ByteArray.toNpub() = Bech32.encodeBytes(hrp = "npub", this, Bech32.Encoding.Bech32)
@Deprecated("Prefer nevent1 instead")
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
fun ByteArray.toNEvent() = Bech32.encodeBytes(hrp = "nevent", this, Bech32.Encoding.Bech32)
@@ -20,43 +20,36 @@
*/
package com.vitorpamplona.quartz.encoders
import com.vitorpamplona.quartz.events.FileHeaderEvent
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException
class Nip54InlineMetadata {
fun convertFromFileHeader(header: FileHeaderEvent): String? {
val myUrl = header.url() ?: return null
return createUrl(
myUrl,
header.tags,
fun createUrl(header: IMetaTag): String =
createUrl(
header.url,
header.properties,
)
}
fun createUrl(
imageUrl: String,
tags: Array<Array<String>>,
url: String,
tags: Map<String, String>,
): String {
val extension =
tags
.mapNotNull {
if (it.isNotEmpty() && it[0] != "url") {
if (it.size > 1) {
"${it[0]}=${URLEncoder.encode(it[1], "utf-8")}"
} else {
"${it[0]}}="
}
if (it.key != "url") {
"${it.key}=${URLEncoder.encode(it.value, "utf-8")}"
} else {
null
}
}.joinToString("&")
return if (imageUrl.contains("#")) {
"$imageUrl&$extension"
return if (url.contains("#")) {
"$url&$extension"
} else {
"$imageUrl#$extension"
"$url#$extension"
}
}
@@ -20,56 +20,108 @@
*/
package com.vitorpamplona.quartz.encoders
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.ALT
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.BLUR_HASH
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.DIMENSION
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.FILE_SIZE
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.HASH
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.MAGNET_URI
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.MIME_TYPE
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.ORIGINAL_HASH
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.TORRENT_INFOHASH
class IMetaTag(
val url: String,
val properties: Map<String, String>,
)
class IMetaTagBuilder(
val url: String,
) {
val properties = mutableMapOf<String, String>()
fun add(
key: String,
value: String,
): IMetaTagBuilder {
properties.set(key, value)
return this
}
fun magnet(uri: String) = add(MAGNET_URI, uri)
fun mimeType(mime: String) = add(MIME_TYPE, mime)
fun alt(alt: String) = add(ALT, alt)
fun hash(hash: HexKey) = add(HASH, hash)
fun size(size: Int) = add(FILE_SIZE, size.toString())
fun dims(dims: Dimension) = add(DIMENSION, dims.toString())
fun blurhash(blurhash: String) = add(BLUR_HASH, blurhash)
fun originalHash(originalHash: String) = add(ORIGINAL_HASH, originalHash)
fun torrent(uri: String) = add(TORRENT_INFOHASH, uri)
fun sensitiveContent(reason: String) = add("content-warning", reason)
fun build() = IMetaTag(url, properties)
}
class Nip92MediaAttachments {
companion object {
private const val IMETA = "imeta"
}
const val IMETA = "imeta"
fun convertFromFileHeader(header: FileHeaderEvent): Array<String>? {
val myUrl = header.url() ?: return null
return createTag(
myUrl,
header.tags,
)
}
fun createTag(header: IMetaTag): Array<String> =
createTag(
header.url,
header.properties,
)
fun createTag(
imageUrl: String,
tags: Array<Array<String>>,
): Array<String> =
arrayOf(
IMETA,
"url $imageUrl",
) +
tags.mapNotNull {
if (it.isNotEmpty() && it[0] != "url") {
if (it.size > 1) {
"${it[0]} ${it[1]}"
fun createTag(
url: String,
tags: Map<String, String>,
): Array<String> =
arrayOf(
IMETA,
"url $url",
) +
tags.mapNotNull {
if (it.key != "url") {
"${it.key} ${it.value}"
} else {
"${it[0]}}"
null
}
} else {
null
}
fun parse(
url: String,
tags: Array<Array<String>>,
): Map<String, String> =
tags
.firstOrNull {
it.size > 1 && it[0] == IMETA && it[1] == "url $url"
}?.let { tagList ->
parseIMeta(tagList)
} ?: emptyMap()
fun parse(tags: Array<Array<String>>): Map<String, Map<String, String>> =
tags.filter { it.size > 1 && it[0] == IMETA }.associate {
val allTags = parseIMeta(it)
(allTags.get("url") ?: "") to allTags
}
fun parse(
imageUrl: String,
tags: Array<Array<String>>,
): Map<String, String> =
tags
.firstOrNull {
it.size > 1 && it[0] == IMETA && it[1] == "url $imageUrl"
}?.let { tagList ->
tagList.associate { tag ->
val parts = tag.split(" ", limit = 2)
when (parts.size) {
2 -> parts[0] to parts[1]
1 -> parts[0] to ""
else -> "" to ""
}
private fun parseIMeta(tags: Array<String>): Map<String, String> =
tags.associate { tag ->
val parts = tag.split(" ", limit = 2)
when (parts.size) {
2 -> parts[0] to parts[1]
1 -> parts[0] to ""
else -> "" to ""
}
} ?: emptyMap()
}
}
}
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2024 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.encoders
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers
@Immutable
data class PTag(
val pubKeyHex: HexKey,
) {
var relay: String? = null
constructor(pubKeyHex: HexKey, relayHint: String?) : this(pubKeyHex) {
this.relay = relayHint?.ifBlank { null }
}
fun countMemory(): Long =
2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit)
pubKeyHex.bytesUsedInMemory() +
(relay?.bytesUsedInMemory() ?: 0)
fun toNProfile(): String = Nip19Bech32.createNProfile(pubKeyHex, relay?.let { listOf(it) } ?: emptyList())
fun toNPub(): String = Nip19Bech32.createNPub(pubKeyHex)
fun toPTagArray() = removeTrailingNullsAndEmptyOthers("p", pubKeyHex, relay)
companion object {
fun parseNAddr(nprofile: String): PTag? {
try {
val parsed = Nip19Bech32.uriToRoute(nprofile)?.entity
return when (parsed) {
is Nip19Bech32.NPub -> PTag(parsed.hex)
is Nip19Bech32.NProfile -> PTag(parsed.hex, parsed.relay.firstOrNull())
else -> null
}
} catch (e: Throwable) {
Log.w("PTag", "Issue trying to Decode NIP19 $this: ${e.message}")
return null
}
}
}
}
@@ -45,8 +45,8 @@ class AppMetadata {
var website: String? = null
var about: String? = null
var subscription: Boolean? = false
var cashuAccepted: Boolean? = false
var encryptionSupported: Boolean? = false
var acceptsNutZaps: Boolean? = false
var supportsEncryption: Boolean? = false
var personalized: Boolean? = false
var amount: String? = null
@@ -71,8 +71,8 @@ class AppMetadata {
(website?.bytesUsedInMemory() ?: 0L) +
(about?.bytesUsedInMemory() ?: 0L) +
(subscription?.bytesUsedInMemory() ?: 0L) +
(cashuAccepted?.bytesUsedInMemory() ?: 0L) +
(encryptionSupported?.bytesUsedInMemory() ?: 0L) +
(acceptsNutZaps?.bytesUsedInMemory() ?: 0L) +
(supportsEncryption?.bytesUsedInMemory() ?: 0L) +
(personalized?.bytesUsedInMemory() ?: 0L) + // A Boolean has 8 bytes of header, plus 1 byte of payload, for a total of 9 bytes of information. The JVM then rounds it up to the next multiple of 8. so the one instance of java.lang.Boolean takes up 16 bytes of memory.
(amount?.bytesUsedInMemory() ?: 0L) +
(nip05?.bytesUsedInMemory() ?: 0L) +
@@ -95,7 +95,7 @@ class AppMetadata {
fun nip05(): String? = nip05
fun profilePicture(): String? = picture
fun profilePicture(): String? = picture ?: image
fun cleanBlankNames() {
if (picture?.isNotEmpty() == true) picture = picture?.trim()
@@ -57,21 +57,15 @@ open class BaseTextNoteEvent(
fun isForkFromAddressWithPubkey(authorHex: HexKey) = tags.any { it.size > 3 && it[0] == "a" && it[3] == "fork" && it[1].contains(authorHex) }
open fun replyTos(): List<HexKey> {
val oldStylePositional = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] }
open fun markedReplyTos(): List<HexKey> {
val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1)
val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1)
val newStyleReplyTos = listOfNotNull(newStyleReply, newStyleRoot)
return if (newStyleReplyTos.isNotEmpty()) {
newStyleReplyTos
} else {
oldStylePositional
}
return listOfNotNull(newStyleReply, newStyleRoot)
}
fun replyingTo(): HexKey? {
open fun unMarkedReplyTos(): List<HexKey> = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] }
open fun replyingTo(): HexKey? {
val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && it[0] == "e" }?.get(1)
val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1)
val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1)
@@ -79,7 +73,7 @@ open class BaseTextNoteEvent(
return newStyleReply ?: newStyleRoot ?: oldStylePositional
}
fun replyingToAddress(): ATag? {
open fun replyingToAddress(): ATag? {
val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && it[0] == "a" }?.let { ATag.parseAtag(it[1], it[2]) }
val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "reply" }?.let { ATag.parseAtag(it[1], it[2]) }
val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "a" && it[3] == "root" }?.let { ATag.parseAtag(it[1], it[2]) }
@@ -87,7 +81,7 @@ open class BaseTextNoteEvent(
return newStyleReply ?: newStyleRoot ?: oldStylePositional
}
fun replyingToAddressOrEvent(): String? {
open fun replyingToAddressOrEvent(): String? {
val oldStylePositional = tags.lastOrNull { it.size > 1 && it.size <= 3 && (it[0] == "e" || it[0] == "a") }?.get(1)
val newStyleReply = tags.lastOrNull { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "reply" }?.get(1)
val newStyleRoot = tags.lastOrNull { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "root" }?.get(1)
@@ -190,21 +184,33 @@ open class BaseTextNoteEvent(
}
fun tagsWithoutCitations(): List<String> {
val repliesTo = replyTos()
val certainRepliesTo = markedReplyTos()
val uncertainRepliesTo = unMarkedReplyTos()
val tagAddresses =
taggedAddresses()
.filter {
it.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || it.kind != WikiNoteEvent.KIND)
// removes forks from itself.
}.map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
if (certainRepliesTo.isEmpty() && uncertainRepliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations()
return if (citations.isEmpty()) {
repliesTo + tagAddresses
if (certainRepliesTo.isNotEmpty()) {
certainRepliesTo + tagAddresses
} else {
uncertainRepliesTo + tagAddresses
}
} else {
repliesTo.filter { it !in citations }
if (certainRepliesTo.isNotEmpty()) {
certainRepliesTo + tagAddresses.filter { it !in citations }
} else {
// mix bag between `e` for replies and `e` for citations
uncertainRepliesTo.filter { it !in citations }
}
}
}
}
@@ -0,0 +1,92 @@
/**
* Copyright (c) 2024 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.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class BlossomAuthorizationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 24242
fun createGetAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) = createAuth("get", hash, null, alt, signer, createdAt, onReady)
fun createListAuth(
signer: NostrSigner,
alt: String,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) = createAuth("list", null, null, alt, signer, createdAt, onReady)
fun createDeleteAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) = createAuth("delete", hash, null, alt, signer, createdAt, onReady)
fun createUploadAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) = createAuth("upload", hash, size, alt, signer, createdAt, onReady)
private fun createAuth(
type: String,
hash: HexKey?,
fileSize: Long?,
alt: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomAuthorizationEvent) -> Unit,
) {
val tags =
listOfNotNull(
arrayOf("t", type),
arrayOf("expiration", TimeUtils.oneHourAhead().toString()),
fileSize?.let { arrayOf("size", it.toString()) },
hash?.let { arrayOf("x", it) },
)
signer.sign(createdAt, KIND, tags.toTypedArray(), alt, onReady)
}
}
}
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2024 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.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class BlossomServersEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun dTag() = FIXED_D_TAG
fun servers(): List<String> =
tags.mapNotNull {
if (it.size > 1 && it[0] == "server") {
it[1]
} else {
null
}
}
companion object {
const val KIND = 10063
const val FIXED_D_TAG = ""
const val ALT = "File servers used by the author"
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressTag(pubKey: HexKey): String = ATag.assembleATag(KIND, pubKey, FIXED_D_TAG)
fun createTagArray(servers: List<String>): Array<Array<String>> =
servers
.map {
arrayOf("server", it)
}.plusElement(arrayOf("alt", ALT))
.toTypedArray()
fun updateRelayList(
earlierVersion: BlossomServersEvent,
relays: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomServersEvent) -> Unit,
) {
val tags =
earlierVersion.tags
.filter { it[0] != "server" }
.plus(
relays.map {
arrayOf("server", it)
},
).toTypedArray()
signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady)
}
fun createFromScratch(
relays: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomServersEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
servers: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlossomServersEvent) -> Unit,
) {
signer.sign(createdAt, KIND, createTagArray(servers), "", onReady)
}
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -40,10 +41,9 @@ class ChannelMessageEvent(
tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1)
?: tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
override fun replyTos() =
tags
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }
.mapNotNull { it.getOrNull(1) }
override fun markedReplyTos() = super.markedReplyTos().filter { it != channel() }
override fun unMarkedReplyTos() = super.unMarkedReplyTos().filter { it != channel() }
companion object {
const val KIND = 42
@@ -59,8 +59,9 @@ class ChannelMessageEvent(
createdAt: Long = TimeUtils.now(),
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
directMentions: Set<HexKey> = emptySet(),
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (ChannelMessageEvent) -> Unit,
) {
@@ -68,8 +69,14 @@ class ChannelMessageEvent(
mutableListOf(
arrayOf("e", channel, "", "root"),
)
replyTos?.forEach { tags.add(arrayOf("e", it)) }
mentions?.forEach { tags.add(arrayOf("p", it)) }
replyTos?.forEach {
if (it in directMentions) {
tags.add(arrayOf("q", it))
} else {
tags.add(arrayOf("e", it))
}
}
zapReceiver?.forEach {
tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
}
@@ -78,12 +85,8 @@ class ChannelMessageEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(
arrayOf("alt", ALT),
@@ -0,0 +1,189 @@
/**
* Copyright (c) 2024 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.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableSet
@Immutable
class ChatMessageEncryptedFileHeaderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
ChatroomKeyable,
NIP17Group {
/** Recipients intended to receive this conversation */
fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null }
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun talkingWith(oneSideHex: String): Set<HexKey> {
val listedPubKeys = recipientsPubKey()
val result =
if (pubKey == oneSideHex) {
listedPubKeys.toSet().minus(oneSideHex)
} else {
listedPubKeys.plus(pubKey).toSet().minus(oneSideHex)
}
if (result.isEmpty()) {
// talking to myself
return setOf(pubKey)
}
return result
}
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
fun url() = content
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
fun algo() = tags.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_ALGORITHM }?.get(1)
fun key() =
tags
.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_KEY }
?.get(1)
?.runCatching { this.hexToByteArray() }
?.getOrNull()
fun nonce() =
tags
.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_NONCE }
?.get(1)
?.runCatching { this.hexToByteArray() }
?.getOrNull()
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun originalHash() = tags.firstOrNull { it.size > 1 && it[0] == ORIGINAL_HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) }
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
companion object {
const val KIND = 15
const val ALT_DESCRIPTION = "Encrypted file in chat"
const val MIME_TYPE = "file-type"
const val ENCRYPTION_ALGORITHM = "encryption-algorithm"
const val ENCRYPTION_KEY = "decryption-key"
const val ENCRYPTION_NONCE = "decryption-nonce"
const val FILE_SIZE = "size"
const val DIMENSION = "dim"
const val BLUR_HASH = "blurhash"
const val HASH = "x"
const val ORIGINAL_HASH = "ox"
const val ALT = "alt"
fun buildTags(
to: List<HexKey>,
repliesTo: List<HexKey>? = null,
contentType: String?,
algo: String,
key: ByteArray,
nonce: ByteArray? = null,
originalHash: String? = null,
hash: String? = null,
size: Int? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
sensitiveContent: Boolean? = null,
alt: String?,
): Array<Array<String>> {
val repliesHex = repliesTo?.map { arrayOf("e", it) } ?: emptyList()
return (
to.map { arrayOf("p", it) } + repliesHex +
listOfNotNull(
contentType?.let { arrayOf(MIME_TYPE, it) },
arrayOf(ENCRYPTION_ALGORITHM, algo),
arrayOf(ENCRYPTION_KEY, key.toHexKey()),
nonce?.let { arrayOf(ENCRYPTION_NONCE, it.toHexKey()) },
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf(ALT, ALT_DESCRIPTION),
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it.toString()) },
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
} else {
null
}
},
)
).toTypedArray()
}
fun create(
url: String,
to: List<HexKey>,
repliesTo: List<HexKey>? = null,
contentType: String?,
algo: String,
key: ByteArray,
nonce: ByteArray? = null,
originalHash: String? = null,
hash: String? = null,
size: Int? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
sensitiveContent: Boolean? = null,
alt: String?,
signer: NostrSigner,
isDraft: Boolean,
createdAt: Long = TimeUtils.now(),
onReady: (ChatMessageEncryptedFileHeaderEvent) -> Unit,
) {
val tags = buildTags(to, repliesTo, contentType, algo, key, nonce, originalHash, hash, size, dimensions, blurhash, sensitiveContent, alt)
if (isDraft) {
signer.assembleRumor(createdAt, KIND, tags, url, onReady)
} else {
signer.sign(createdAt, KIND, tags, url, onReady)
}
}
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -37,7 +38,8 @@ class ChatMessageEvent(
content: String,
sig: HexKey,
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
ChatroomKeyable {
ChatroomKeyable,
NIP17Group {
/** Recipients intended to receive this conversation */
fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null }
@@ -61,6 +63,8 @@ class ChatMessageEvent(
return result
}
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
companion object {
@@ -79,7 +83,7 @@ class ChatMessageEvent(
geohash: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (ChatMessageEvent) -> Unit,
) {
@@ -96,12 +100,8 @@ class ChatMessageEvent(
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
subject?.let { tags.add(arrayOf("subject", it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
// tags.add(arrayOf("alt", alt))
@@ -114,6 +114,10 @@ class ChatMessageEvent(
}
}
interface NIP17Group {
fun groupMembers(): Set<HexKey>
}
interface ChatroomKeyable {
fun chatroomKey(toRemove: HexKey): ChatroomKey
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -112,7 +114,7 @@ class ClassifiedsEvent(
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<Event>? = null,
imetas: List<IMetaTag>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
isDraft: Boolean,
@@ -188,10 +190,8 @@ class ClassifiedsEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
// tags.add(arrayOf("nip94", it.toJson()))
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", ALT))
@@ -0,0 +1,224 @@
/**
* Copyright (c) 2024 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.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.ETag
import com.vitorpamplona.quartz.encoders.EventHint
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.encoders.PTag
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers
@Immutable
class CommentEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
fun root() = tags.firstOrNull { it.size > 3 && it[3] == "root" }?.get(1)
fun getRootScopes() = tags.filter { it.size > 1 && it[0] == "I" || it[0] == "A" || it[0] == "E" }
fun getRootKinds() = tags.filter { it.size > 1 && it[0] == "K" }
fun getDirectReplies() = tags.filter { it.size > 1 && it[0] == "i" || it[0] == "a" || it[0] == "e" }
fun getDirectKinds() = tags.filter { it.size > 1 && it[0] == "k" }
fun isGeohashTag(tag: Array<String>) = tag.size > 1 && (tag[0] == "i" || tag[0] == "I") && tag[1].startsWith("geo:")
private fun getGeoHashList() = tags.filter { isGeohashTag(it) }
override fun hasGeohashes() = tags.any { isGeohashTag(it) }
override fun geohashes() = getGeoHashList().map { it[1].drop(4).lowercase() }
override fun getGeoHash(): String? = geohashes().maxByOrNull { it.length }
override fun isTaggedGeoHash(hashtag: String) = tags.any { isGeohashTag(it) && it[1].endsWith(hashtag, true) }
override fun isTaggedGeoHashes(hashtags: Set<String>) = geohashes().any { it in hashtags }
override fun markedReplyTos(): List<HexKey> = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + tags.filter { it.size > 1 && it[0] == "E" }.map { it[1] }
override fun unMarkedReplyTos() = emptyList<String>()
override fun replyingTo(): HexKey? =
tags.lastOrNull { it.size > 1 && it[0] == "e" }?.get(1)
?: tags.lastOrNull { it.size > 1 && it[0] == "E" }?.get(1)
override fun replyingToAddress(): ATag? =
tags.lastOrNull { it.size > 1 && it[0] == "a" }?.let { ATag.parseAtag(it[1], it.getOrNull(2)) }
?: tags.lastOrNull { it.size > 1 && it[0] == "A" }?.let { ATag.parseAtag(it[1], it.getOrNull(2)) }
override fun replyingToAddressOrEvent(): HexKey? = replyingToAddress()?.toTag() ?: replyingTo()
companion object {
const val KIND = 1111
fun rootGeohashMipMap(geohash: String): Array<Array<String>> =
geohash.indices
.asSequence()
.map { arrayOf("I", "geo:" + geohash.substring(0, it + 1)) }
.toList()
.reversed()
.toTypedArray()
fun firstReplyToEvent(
msg: String,
replyingTo: EventHint<Event>,
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
imetas: List<IMetaTag>? = null,
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
isDraft: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (CommentEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
if (replyingTo.event is AddressableEvent) {
tags.add(removeTrailingNullsAndEmptyOthers("A", replyingTo.event.addressTag(), replyingTo.relay))
tags.add(removeTrailingNullsAndEmptyOthers("a", replyingTo.event.addressTag(), replyingTo.relay))
}
tags.add(removeTrailingNullsAndEmptyOthers("E", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey))
tags.add(arrayOf("K", "${replyingTo.event.kind}"))
tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey))
tags.add(arrayOf("k", "${replyingTo.event.kind}"))
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
}
fun replyComment(
msg: String,
replyingTo: EventHint<CommentEvent>,
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
imetas: List<IMetaTag>? = null,
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
isDraft: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (CommentEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
tags.addAll(replyingTo.event.getRootScopes())
tags.addAll(replyingTo.event.getRootKinds())
tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey))
tags.add(arrayOf("k", "${replyingTo.event.kind}"))
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
}
fun createGeoComment(
msg: String,
geohash: String? = null,
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
imetas: List<IMetaTag>? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
isDraft: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (CommentEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
geohash?.let { tags.addAll(rootGeohashMipMap(it)) }
tags.add(arrayOf("K", "geo"))
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, null, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
}
private fun create(
msg: String,
tags: MutableList<Array<String>>,
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
imetas: List<IMetaTag>? = null,
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
isDraft: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (CommentEvent) -> Unit,
) {
usersMentioned.forEach { tags.add(it.toPTagArray()) }
addressesMentioned.forEach { tags.add(it.toQTagArray()) }
eventsMentioned.forEach { tags.add(it.toQTagArray()) }
findHashtags(msg).forEach {
val lowercaseTag = it.lowercase()
tags.add(arrayOf("t", it))
if (it != lowercaseTag) {
tags.add(arrayOf("t", it.lowercase()))
}
}
findURLs(msg).forEach { tags.add(arrayOf("r", it)) }
zapReceiver?.forEach {
tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
}
if (markAsSensitive) {
tags.add(arrayOf("content-warning", ""))
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
if (isDraft) {
signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), msg, onReady)
} else {
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
}
}
}
}
@@ -85,8 +85,6 @@ class ContactListEvent(
fun unverifiedFollowGeohashSet() = tags.filter { it.size > 1 && it[0] == "g" }.mapNotNull { it.getOrNull(1) }
fun unverifiedFollowAddressSet() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { it.getOrNull(1) }
fun follows() =
tags.mapNotNull {
try {
@@ -141,21 +139,11 @@ class ContactListEvent(
val tags =
listOf(arrayOf("alt", ALT)) +
followUsers.map {
if (it.relayUri != null) {
arrayOf("p", it.pubKeyHex, it.relayUri)
} else {
arrayOf("p", it.pubKeyHex)
}
listOfNotNull("p", it.pubKeyHex, it.relayUri).toTypedArray()
} +
followTags.map { arrayOf("t", it) } +
followEvents.map { arrayOf("e", it) } +
followCommunities.map {
if (it.relay != null) {
arrayOf("a", it.toTag(), it.relay)
} else {
arrayOf("a", it.toTag())
}
} +
followCommunities.map { it.toATagArray() } +
followGeohashes.map { arrayOf("g", it) }
return signer.sign(createdAt, KIND, tags.toTypedArray(), content)
@@ -189,13 +177,7 @@ class ContactListEvent(
} +
followTags.map { arrayOf("t", it) } +
followEvents.map { arrayOf("e", it) } +
followCommunities.map {
if (it.relay != null) {
arrayOf("a", it.toTag(), it.relay)
} else {
arrayOf("a", it.toTag())
}
} +
followCommunities.map { it.toATagArray() } +
followGeohashes.map { arrayOf("g", it) }
return create(
@@ -452,6 +434,7 @@ class UserMetadata {
var website: String? = null
var about: String? = null
var bot: Boolean? = null
var pronouns: String? = null
var nip05: String? = null
var nip05Verified: Boolean = false
@@ -482,6 +465,8 @@ class UserMetadata {
fun profilePicture(): String? = picture
fun cleanBlankNames() {
if (pronouns == "null") pronouns = null
if (picture?.isNotEmpty() == true) picture = picture?.trim()
if (nip05?.isNotEmpty() == true) nip05 = nip05?.trim()
if (displayName?.isNotEmpty() == true) displayName = displayName?.trim()
@@ -489,6 +474,7 @@ class UserMetadata {
if (username?.isNotEmpty() == true) username = username?.trim()
if (lud06?.isNotEmpty() == true) lud06 = lud06?.trim()
if (lud16?.isNotEmpty() == true) lud16 = lud16?.trim()
if (pronouns?.isNotEmpty() == true) pronouns = pronouns?.trim()
if (banner?.isNotEmpty() == true) banner = banner?.trim()
if (website?.isNotEmpty() == true) website = website?.trim()
@@ -505,6 +491,7 @@ class UserMetadata {
if (banner?.isBlank() == true) banner = null
if (website?.isBlank() == true) website = null
if (domain?.isBlank() == true) domain = null
if (pronouns?.isBlank() == true) pronouns = null
}
}
@@ -132,6 +132,17 @@ class DraftEvent(
create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady)
}
fun create(
dTag: String,
originalNote: InteractiveStoryBaseEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
create(dTag, originalNote, tags, signer, createdAt, onReady)
}
fun create(
dTag: String,
originalNote: LiveActivitiesChatMessageEvent,
@@ -188,6 +199,18 @@ class DraftEvent(
create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady)
}
fun create(
dTag: String,
originalNote: CommentEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
val tagsWithMarkers = originalNote.getRootScopes() + originalNote.getDirectReplies()
create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady)
}
fun create(
dTag: String,
originalNote: TextNoteEvent,
@@ -33,7 +33,6 @@ import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.fasterxml.jackson.module.kotlin.addDeserializer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.ATag
@@ -50,6 +49,8 @@ import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import com.vitorpamplona.quartz.utils.remove
import com.vitorpamplona.quartz.utils.startsWith
import java.math.BigDecimal
import java.security.MessageDigest
@@ -280,7 +281,13 @@ open class Event(
return PoWRank.getCommited(id, commitedPoW)
}
override fun getGeoHash(): String? = tags.firstOrNull { it.size > 1 && it[0] == "g" }?.get(1)?.ifBlank { null }
override fun getGeoHash(): String? =
tags
.filter { it.size > 1 && it[0] == "g" }
.maxByOrNull {
it[1].length
}?.get(1)
?.ifBlank { null }
override fun getReward(): BigDecimal? =
try {
@@ -289,6 +296,8 @@ open class Event(
null
}
fun filterTags(startsWith: Array<String>) = tags.remove(startsWith)
open fun toNIP19(): String =
if (this is AddressableEvent) {
ATag(kind, pubKey, dTag(), null).toNAddr()
@@ -550,7 +559,7 @@ class HostStub(
interface AddressableEvent {
fun dTag(): String
fun address(): ATag
fun address(relayHint: String? = null): ATag
fun addressTag(): String
}
@@ -568,7 +577,7 @@ open class BaseAddressableEvent(
AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint)
/**
* Creates the tag in a memory effecient way (without creating the ATag class
@@ -582,3 +591,5 @@ data class ZapSplitSetup(
val weight: Double,
val isLnAddress: Boolean,
)
interface RootScope
@@ -20,15 +20,13 @@
*/
package com.vitorpamplona.quartz.events
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.nip46.NostrConnectEvent
class EventFactory {
companion object {
val additionalFactories =
mutableMapOf(
WikiNoteEvent.KIND to ::WikiNoteEvent,
)
val factories: MutableMap<Int, (HexKey, HexKey, Long, Array<Array<String>>, String, HexKey) -> Event> = mutableMapOf()
fun create(
id: String,
@@ -48,6 +46,8 @@ class EventFactory {
BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
BlossomServersEvent.KIND -> BlossomServersEvent(id, pubKey, createdAt, tags, content, sig)
BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig)
BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig)
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
@@ -59,6 +59,20 @@ class EventFactory {
ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
ChatMessageEncryptedFileHeaderEvent.KIND -> {
if (id.isBlank()) {
ChatMessageEncryptedFileHeaderEvent(
Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey(),
pubKey,
createdAt,
tags,
content,
sig,
)
} else {
ChatMessageEncryptedFileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
}
}
ChatMessageEvent.KIND -> {
if (id.isBlank()) {
ChatMessageEvent(
@@ -75,6 +89,7 @@ class EventFactory {
}
ChatMessageRelayListEvent.KIND -> ChatMessageRelayListEvent(id, pubKey, createdAt, tags, content, sig)
ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig)
CommentEvent.KIND -> CommentEvent(id, pubKey, createdAt, tags, content, sig)
CommunityDefinitionEvent.KIND -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
CommunityListEvent.KIND -> CommunityListEvent(id, pubKey, createdAt, tags, content, sig)
CommunityPostApprovalEvent.KIND -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
@@ -98,6 +113,9 @@ class EventFactory {
GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig)
InteractiveStoryPrologueEvent.KIND -> InteractiveStoryPrologueEvent(id, pubKey, createdAt, tags, content, sig)
InteractiveStorySceneEvent.KIND -> InteractiveStorySceneEvent(id, pubKey, createdAt, tags, content, sig)
InteractiveStoryReadingStateEvent.KIND -> InteractiveStoryReadingStateEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
@@ -117,12 +135,14 @@ class EventFactory {
NIP90UserDiscoveryResponseEvent.KIND -> NIP90UserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig)
OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig)
PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.KIND -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig)
RelationshipStatusEvent.KIND -> RelationshipStatusEvent(id, pubKey, createdAt, tags, content, sig)
RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig)
RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
@@ -139,7 +159,7 @@ class EventFactory {
VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig)
WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig)
else -> {
additionalFactories[kind]?.let {
factories[kind]?.let {
return it(id, pubKey, createdAt, tags, content, sig)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -38,8 +39,6 @@ class FileHeaderEvent(
fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] }
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
@@ -48,7 +47,7 @@ class FileHeaderEvent(
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) }
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
@@ -76,6 +75,41 @@ class FileHeaderEvent(
const val ORIGINAL_HASH = "ox"
const val ALT = "alt"
fun buildTags(
url: String,
magnetUri: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
sensitiveContent: Boolean? = null,
): Array<Array<String>> =
listOfNotNull(
arrayOf(URL, url),
magnetUri?.let { arrayOf(MAGNET_URI, it) },
mimeType?.let { arrayOf(MIME_TYPE, it) },
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION),
hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
} else {
null
}
},
).toTypedArray()
fun create(
url: String,
magnetUri: String? = null,
@@ -83,47 +117,20 @@ class FileHeaderEvent(
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (FileHeaderEvent) -> Unit,
) {
val tags =
listOfNotNull(
arrayOf(URL, url),
magnetUri?.let { arrayOf(MAGNET_URI, it) },
mimeType?.let { arrayOf(MIME_TYPE, it) },
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION),
hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
} else {
null
}
},
)
val tags = buildTags(url, magnetUri, mimeType, alt, hash, size, dimensions, blurhash, originalHash, magnetURI, torrentInfoHash, sensitiveContent)
val content = alt ?: ""
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
signer.sign(createdAt, KIND, tags, content, onReady)
}
}
}
data class AESGCM(
val key: String,
val nonce: String,
)
@@ -40,8 +40,6 @@ class FileStorageEvent(
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) }
fun decode(): ByteArray? =
try {
Base64.getDecoder().decode(content)
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -36,8 +37,6 @@ class FileStorageHeaderEvent(
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
@@ -46,7 +45,7 @@ class FileStorageHeaderEvent(
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) }
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
@@ -76,11 +75,10 @@ class FileStorageHeaderEvent(
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
@@ -93,11 +91,10 @@ class FileStorageHeaderEvent(
hash?.let { arrayOf(HASH, it) },
alt?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION),
size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it) },
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
@@ -20,14 +20,10 @@
*/
package com.vitorpamplona.quartz.events
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
import java.util.HashSet
@@ -41,15 +37,7 @@ abstract class GeneralListEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient private var privateTagsCache: Array<Array<String>>? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0)
override fun isContentEncoded() = true
) : PrivateTagArrayEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun category() = dTag()
fun bookmarkedPosts() = taggedEvents()
@@ -64,8 +52,6 @@ abstract class GeneralListEvent(
fun nameOrTitle() = name()?.ifBlank { null } ?: title()?.ifBlank { null }
fun cachedPrivateTags(): Array<Array<String>>? = privateTagsCache
fun filterTagList(
key: String,
privateTags: Array<Array<String>>?,
@@ -95,30 +81,6 @@ abstract class GeneralListEvent(
onReady(isTagged(key, tag))
}
fun privateTags(
signer: NostrSigner,
onReady: (Array<Array<String>>) -> Unit,
) {
if (content.isEmpty()) {
onReady(emptyArray())
return
}
privateTagsCache?.let {
onReady(it)
return
}
try {
signer.decrypt(content, pubKey) {
privateTagsCache = mapper.readValue<Array<Array<String>>>(it)
privateTagsCache?.let { onReady(it) }
}
} catch (e: Throwable) {
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
}
}
fun privateTagsOrEmpty(
signer: NostrSigner,
onReady: (Array<Array<String>>) -> Unit,
@@ -77,13 +77,7 @@ class GiftWrapEvent(
onReady: (Event) -> Unit,
) {
plainContent(signer) { giftStr ->
val gift =
try {
fromJson(giftStr)
} catch (e: Exception) {
Log.w("GiftWrapEvent", "Couldn't Parse the content " + this.toNostrUri() + " " + giftStr)
return@plainContent
}
val gift = fromJson(giftStr)
if (gift is WrappedEvent) {
gift.host = HostStub(this.id, this.pubKey, this.kind)
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -90,7 +91,7 @@ class GitReplyEvent(
root: String? = null,
directMentions: Set<HexKey> = emptySet(),
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
forkedFrom: Event? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
@@ -148,12 +149,8 @@ class GitReplyEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", "a git issue reply"))
@@ -0,0 +1,112 @@
/**
* Copyright (c) 2024 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.events
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
open class InteractiveStoryBaseEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun title() = firstTag("title")
fun summary() = firstTag("summary")
fun image() = firstTag("image")
fun options() =
tags
.filter { it.size > 2 && it[0] == "option" }
.mapNotNull { ATag.parse(it[2], it.getOrNull(3))?.let { aTag -> StoryOption(it[1], aTag) } }
companion object {
fun generalTags(
content: String,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
imetas: List<IMetaTag>? = null,
): Array<Array<String>> {
val tags = mutableListOf<Array<String>>()
findHashtags(content).forEach {
val lowercaseTag = it.lowercase()
tags.add(arrayOf("t", it))
if (it != lowercaseTag) {
tags.add(arrayOf("t", it.lowercase()))
}
}
findURLs(content).forEach { tags.add(arrayOf("r", it)) }
zapReceiver?.forEach {
tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
}
if (markAsSensitive) {
tags.add(arrayOf("content-warning", ""))
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
return tags.toTypedArray()
}
fun makeTags(
baseId: String,
alt: String,
title: String,
summary: String? = null,
image: String? = null,
options: List<StoryOption> = emptyList(),
): Array<Array<String>> =
(
listOfNotNull(
arrayOf("d", baseId),
arrayOf("title", title),
summary?.let { arrayOf("summary", it) },
image?.let { arrayOf("image", it) },
arrayOf("alt", alt),
) +
options.map {
val relayUrl = it.address.relay
if (relayUrl != null) {
arrayOf("option", it.option, it.address.toTag(), relayUrl)
} else {
arrayOf("option", it.option, it.address.toTag())
}
}
).toTypedArray()
}
}
class StoryOption(
val option: String,
val address: ATag,
)
@@ -0,0 +1,80 @@
/**
* Copyright (c) 2024 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.events
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
class InteractiveStoryPrologueEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : InteractiveStoryBaseEvent(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
companion object {
const val KIND = 30296
const val ALT = "The prologue of an interative story called "
fun createAddressATag(
pubKey: HexKey,
dtag: String,
): ATag = ATag(KIND, pubKey, dtag, null)
fun createAddressTag(
pubKey: HexKey,
dtag: String,
): String = ATag.assembleATag(KIND, pubKey, dtag)
fun create(
baseId: String,
title: String,
content: String,
options: List<StoryOption>,
summary: String? = null,
image: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
imetas: List<IMetaTag>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
isDraft: Boolean,
onReady: (InteractiveStoryPrologueEvent) -> Unit,
) {
val tags =
makeTags(baseId, ALT + title, title, summary, image, options) +
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, imetas)
if (isDraft) {
signer.assembleRumor(createdAt, KIND, tags, content, onReady)
} else {
signer.sign(createdAt, KIND, tags, content, onReady)
}
}
}
}
@@ -0,0 +1,143 @@
/**
* Copyright (c) 2024 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.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers
@Immutable
class InteractiveStoryReadingStateEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun title() = firstTag("title")
fun summary() = firstTag("summary")
fun image() = firstTag("image")
fun status() = firstTag("status")
fun root() =
tags.firstOrNull { it.size > 1 && it[0] == "A" }?.let {
ATag.parse(it[1], it.getOrNull(2))
}
fun currentScene() =
tags.firstOrNull { it.size > 1 && it[0] == "a" }?.let {
ATag.parse(it[1], it.getOrNull(2))
}
companion object {
const val KIND = 30298
const val ALT1 = "Interactive Story Reading state"
const val ALT2 = "The reading state of "
fun createAddressATag(
pubKey: HexKey,
dtag: String,
): ATag = ATag(KIND, pubKey, dtag, null)
fun createAddressTag(
pubKey: HexKey,
dtag: String,
): String = ATag.assembleATag(KIND, pubKey, dtag)
fun update(
base: InteractiveStoryReadingStateEvent,
currentScene: InteractiveStoryBaseEvent,
currentSceneRelay: String?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (InteractiveStoryReadingStateEvent) -> Unit,
) {
val rootTag = base.dTag()
val sceneTag = currentScene.addressTag()
val status =
if (rootTag == sceneTag) {
"new"
} else if (currentScene.options().isEmpty()) {
"done"
} else {
"reading"
}
val tags =
base.tags.filter { it[0] != "a" && it[0] != "status" } +
listOf(
removeTrailingNullsAndEmptyOthers("a", sceneTag, currentSceneRelay),
arrayOf("status", status),
)
signer.sign(createdAt, KIND, tags.toTypedArray(), "", onReady)
}
fun create(
root: InteractiveStoryBaseEvent,
rootRelay: String?,
currentScene: InteractiveStoryBaseEvent,
currentSceneRelay: String?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (InteractiveStoryReadingStateEvent) -> Unit,
) {
val rootTag = root.addressTag()
val sceneTag = currentScene.addressTag()
val status =
if (rootTag == sceneTag) {
"new"
} else if (currentScene.options().isEmpty()) {
"done"
} else {
"reading"
}
val tags =
listOfNotNull(
arrayOf("d", rootTag),
arrayOf("alt", root.title()?.let { ALT2 + it } ?: ALT1),
root.title()?.let { arrayOf("title", it) },
root.summary()?.let { arrayOf("summary", it) },
root.image()?.let { arrayOf("image", it) },
removeTrailingNullsAndEmptyOthers("A", rootTag, rootRelay),
removeTrailingNullsAndEmptyOthers("a", sceneTag, currentSceneRelay),
arrayOf("status", status),
).toTypedArray()
signer.sign(createdAt, KIND, tags, "", onReady)
}
}
enum class ReadingStatus {
NEW,
READING,
DONE,
}
}
@@ -0,0 +1,78 @@
/**
* Copyright (c) 2024 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.events
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
class InteractiveStorySceneEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : InteractiveStoryBaseEvent(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
companion object {
const val KIND = 30297
const val ALT = "A scene of an interative story called "
fun createAddressATag(
pubKey: HexKey,
dtag: String,
): ATag = ATag(KIND, pubKey, dtag, null)
fun createAddressTag(
pubKey: HexKey,
dtag: String,
): String = ATag.assembleATag(KIND, pubKey, dtag)
fun create(
baseId: String,
title: String,
content: String,
options: List<StoryOption>,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
imetas: List<IMetaTag>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
isDraft: Boolean,
onReady: (InteractiveStorySceneEvent) -> Unit,
) {
val tags =
makeTags(baseId, ALT + title, title, options = options) +
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, imetas)
if (isDraft) {
signer.assembleRumor(createdAt, KIND, tags, content, onReady)
} else {
signer.sign(createdAt, KIND, tags, content, onReady)
}
}
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -54,7 +55,9 @@ class LiveActivitiesChatMessageEvent(
}
}
override fun replyTos() = taggedEvents().minus(activityHex() ?: "")
override fun markedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "")
override fun unMarkedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "")
companion object {
const val KIND = 1311
@@ -71,7 +74,7 @@ class LiveActivitiesChatMessageEvent(
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (LiveActivitiesChatMessageEvent) -> Unit,
) {
@@ -90,12 +93,8 @@ class LiveActivitiesChatMessageEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", ALT))
@@ -38,7 +38,7 @@ class LongTextNoteEvent(
AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint)
override fun addressTag() = ATag.assembleATag(kind, pubKey, dTag())
@@ -208,6 +208,7 @@ class MetadataEvent(
nip05: String?,
lnAddress: String?,
lnURL: String?,
pronouns: String?,
twitter: String?,
mastodon: String?,
github: String?,
@@ -231,6 +232,7 @@ class MetadataEvent(
picture?.let { addIfNotBlank(currentJson, "picture", it.trim()) }
banner?.let { addIfNotBlank(currentJson, "banner", it.trim()) }
website?.let { addIfNotBlank(currentJson, "website", it.trim()) }
pronouns?.let { addIfNotBlank(currentJson, "pronouns", it.trim()) }
about?.let { addIfNotBlank(currentJson, "about", it.trim()) }
nip05?.let { addIfNotBlank(currentJson, "nip05", it.trim()) }
lnAddress?.let { addIfNotBlank(currentJson, "lud16", it.trim()) }
@@ -281,7 +283,7 @@ class MetadataEvent(
key: String,
value: String,
) {
if (value.isBlank()) {
if (value.isBlank() || value == "null") {
currentJson.remove(key)
} else {
currentJson.put(key, value.trim())
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.quartz.events
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.signers.NostrSigner
class NIP17Factory {
@@ -79,7 +81,7 @@ class NIP17Factory {
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String? = null,
onReady: (Result) -> Unit,
) {
@@ -97,7 +99,66 @@ class NIP17Factory {
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
isDraft = draftTag != null,
nip94attachments = nip94attachments,
imetas = imetas,
) { senderMessage ->
if (draftTag != null) {
onReady(
Result(
msg = senderMessage,
wraps = listOf(),
),
)
} else {
createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps ->
onReady(
Result(
msg = senderMessage,
wraps = wraps,
),
)
}
}
}
}
fun createEncryptedFileNIP17(
url: String,
to: List<HexKey>,
repliesToHex: List<HexKey>? = null,
contentType: String?,
algo: String,
key: ByteArray,
nonce: ByteArray? = null,
originalHash: String? = null,
hash: String? = null,
size: Int? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
sensitiveContent: Boolean? = null,
alt: String?,
draftTag: String? = null,
signer: NostrSigner,
onReady: (Result) -> Unit,
) {
val senderPublicKey = signer.pubKey
ChatMessageEncryptedFileHeaderEvent.create(
url = url,
to = to,
repliesTo = repliesToHex,
contentType = contentType,
algo = algo,
key = key,
nonce = nonce,
originalHash = originalHash,
hash = hash,
size = size,
dimensions = dimensions,
blurhash = blurhash,
sensitiveContent = sensitiveContent,
alt = alt,
signer = signer,
isDraft = draftTag != null,
) { senderMessage ->
if (draftTag != null) {
onReady(
@@ -0,0 +1,358 @@
/**
* Copyright (c) 2024 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.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.ETag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments.Companion.IMETA
import com.vitorpamplona.quartz.encoders.PTag
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class PictureEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
fun mimeTypes() = tags.filter { it.size > 1 && it[0] == MIME_TYPE }
fun hashes() = tags.filter { it.size > 1 && it[0] == HASH }
fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1)
private fun url() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.URL }?.get(1)
private fun urls() = tags.filter { it.size > 1 && it[0] == PictureMeta.URL }.map { it[1] }
private fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.MIME_TYPE }?.get(1)
private fun hash() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.HASH }?.get(1)
private fun size() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.FILE_SIZE }?.get(1)
private fun alt() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.ALT }?.get(1)
private fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.DIMENSION }?.get(1)?.let { Dimension.parse(it) }
private fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == PictureMeta.BLUR_HASH }?.get(1)
private fun hasUrl() = tags.any { it.size > 1 && it[0] == PictureMeta.URL }
// hack to fix pablo's bug
fun rootImage() =
url()?.let {
PictureMeta(
url = it,
mimeType = mimeType(),
blurhash = blurhash(),
alt = alt(),
hash = hash(),
dimension = dimensions(),
size = size()?.toLongOrNull(),
fallback = emptyList(),
annotations = emptyList(),
)
}
fun imetaTags() =
tags
.map { tagArray ->
if (tagArray.size > 1 && tagArray[0] == IMETA) {
PictureMeta.parse(tagArray)
} else {
null
}
}.plus(rootImage())
.filterNotNull()
companion object {
const val KIND = 20
const val ALT_DESCRIPTION = "List of pictures"
private const val MIME_TYPE = "m"
private const val HASH = "x"
private const val TITLE = "title"
fun create(
url: String,
msg: String? = null,
title: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: Long? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PictureEvent) -> Unit,
) {
val image =
PictureMeta(
url,
mimeType,
blurhash,
dimensions,
alt,
hash,
size,
emptyList(),
emptyList(),
)
create(listOf(image), msg, title, usersMentioned, addressesMentioned, eventsMentioned, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, signer, createdAt, onReady)
}
fun create(
images: List<PictureMeta>,
msg: String? = null,
title: String? = null,
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PictureEvent) -> Unit,
) {
val tags = mutableListOf(arrayOf<String>("alt", ALT_DESCRIPTION))
images.forEach {
tags.add(it.toIMetaArray())
}
title?.let { tags.add(arrayOf("title", it)) }
images.distinctBy { it.hash }.forEach {
if (it.hash != null) {
tags.add(arrayOf("x", it.hash))
}
}
images.distinctBy { it.mimeType }.forEach {
if (it.mimeType != null) {
tags.add(arrayOf("m", it.mimeType))
}
}
usersMentioned.forEach { tags.add(it.toPTagArray()) }
addressesMentioned.forEach { tags.add(it.toQTagArray()) }
eventsMentioned.forEach { tags.add(it.toQTagArray()) }
if (msg != null) {
findHashtags(msg).forEach {
val lowercaseTag = it.lowercase()
tags.add(arrayOf("t", it))
if (it != lowercaseTag) {
tags.add(arrayOf("t", it.lowercase()))
}
}
findURLs(msg).forEach { tags.add(arrayOf("r", it)) }
}
zapReceiver?.forEach {
tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
}
if (markAsSensitive) {
tags.add(arrayOf("content-warning", ""))
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
signer.sign(createdAt, KIND, tags.toTypedArray(), msg ?: "", onReady)
}
}
}
class PictureMeta(
val url: String,
val mimeType: String?,
val blurhash: String?,
val dimension: Dimension?,
val alt: String?,
val hash: String?,
val size: Long?,
val fallback: List<String>,
val annotations: List<UserAnnotation>,
) {
fun toIMetaArray(): Array<String> =
(
listOfNotNull(
"imeta",
"$URL $url",
mimeType?.let { "$MIME_TYPE $it" },
alt?.let { "$ALT $it" },
hash?.let { "$HASH $it" },
size?.let { "$FILE_SIZE $it" },
dimension?.let { "$DIMENSION $it" },
blurhash?.let { "$BLUR_HASH $it" },
) +
fallback.map { "$FALLBACK $it" } +
annotations.map { "$ANNOTATIONS $it" }
).toTypedArray()
companion object {
const val URL = "url"
const val MIME_TYPE = "m"
const val FILE_SIZE = "size"
const val DIMENSION = "dim"
const val HASH = "x"
const val BLUR_HASH = "blurhash"
const val ALT = "alt"
const val FALLBACK = "fallback"
const val ANNOTATIONS = "annotate-user"
fun parse(tagArray: Array<String>): PictureMeta? {
var url: String? = null
var mimeType: String? = null
var blurhash: String? = null
var dim: Dimension? = null
var alt: String? = null
var hash: String? = null
var size: Long? = null
val fallback = mutableListOf<String>()
val annotations = mutableListOf<UserAnnotation>()
if (tagArray.size == 2 && tagArray[1].contains(URL) && (tagArray[1].contains(BLUR_HASH) || tagArray[1].contains(FILE_SIZE))) {
// hack to fix pablo's bug
val keys = setOf(URL, MIME_TYPE, BLUR_HASH, DIMENSION, ALT, HASH, FILE_SIZE, FALLBACK, ANNOTATIONS)
var keyNextValue: String? = null
val values = mutableListOf<String>()
tagArray[1].split(" ").forEach {
if (it in keys) {
if (keyNextValue != null && values.isNotEmpty()) {
when (keyNextValue) {
URL -> url = values.joinToString(" ")
MIME_TYPE -> mimeType = values.joinToString(" ")
BLUR_HASH -> blurhash = values.joinToString(" ")
DIMENSION -> dim = Dimension.parse(values.joinToString(" "))
ALT -> alt = values.joinToString(" ")
HASH -> hash = values.joinToString(" ")
FILE_SIZE -> size = values.joinToString(" ").toLongOrNull()
FALLBACK -> fallback.add(values.joinToString(" "))
ANNOTATIONS -> {
UserAnnotation.parse(values.joinToString(" "))?.let {
annotations.add(it)
}
}
}
values.clear()
}
keyNextValue = it
} else {
values.add(it)
}
}
if (keyNextValue != null && values.isNotEmpty()) {
when (keyNextValue) {
URL -> url = values.joinToString(" ")
MIME_TYPE -> mimeType = values.joinToString(" ")
BLUR_HASH -> blurhash = values.joinToString(" ")
DIMENSION -> dim = Dimension.parse(values.joinToString(" "))
ALT -> alt = values.joinToString(" ")
HASH -> hash = values.joinToString(" ")
FILE_SIZE -> size = values.joinToString(" ").toLongOrNull()
FALLBACK -> fallback.add(values.joinToString(" "))
ANNOTATIONS -> {
UserAnnotation.parse(values.joinToString(" "))?.let {
annotations.add(it)
}
}
}
values.clear()
keyNextValue = null
}
} else {
tagArray.forEach {
val parts = it.split(" ", limit = 2)
val key = parts[0]
val value = if (parts.size == 2) parts[1] else ""
if (value.isNotBlank()) {
when (key) {
URL -> url = value
MIME_TYPE -> mimeType = value
BLUR_HASH -> blurhash = value
DIMENSION -> dim = Dimension.parse(value)
ALT -> alt = value
HASH -> hash = value
FILE_SIZE -> size = value.toLongOrNull()
FALLBACK -> fallback.add(value)
ANNOTATIONS -> {
UserAnnotation.parse(value)?.let {
annotations.add(it)
}
}
}
}
}
}
return url?.let {
PictureMeta(it, mimeType, blurhash, dim, alt, hash, size, fallback, annotations)
}
}
}
}
class UserAnnotation(
val pubkey: HexKey,
val x: Int,
val y: Int,
) {
override fun toString() = "$pubkey:$x:$y"
companion object {
fun parse(value: String): UserAnnotation? {
val ann = value.split(":")
if (ann.size == 3) {
val x = ann[1].toIntOrNull()
val y = ann[2].toIntOrNull()
if (x != null && y != null) {
return UserAnnotation(ann[0], x, y)
}
}
return null
}
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -79,7 +80,7 @@ class PollNoteEvent(
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (PollNoteEvent) -> Unit,
) {
@@ -104,12 +105,8 @@ class PollNoteEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", ALT))
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -124,16 +125,13 @@ class PrivateDmEvent(
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (PrivateDmEvent) -> Unit,
) {
var message = msg
nip94attachments?.forEach {
val myUrl = it.url()
if (myUrl != null) {
message = message.replace(myUrl, Nip54InlineMetadata().createUrl(myUrl, it.tags))
}
imetas?.forEach {
message = message.replace(it.url, Nip54InlineMetadata().createUrl(it.url, it.properties))
}
message =
@@ -156,13 +154,10 @@ class PrivateDmEvent(
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
/* Privacy issue: DO NOT ADD THESE TO THE TAGS.
nip94attachments?.let {
it.forEach {
Nip92().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
}*/
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
*/
tags.add(arrayOf("alt", ALT))
@@ -0,0 +1,271 @@
/**
* Copyright (c) 2024 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.events
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import com.vitorpamplona.quartz.utils.remove
import com.vitorpamplona.quartz.utils.replaceAll
@Immutable
abstract class PrivateTagArrayEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient private var privateTagsCache: Array<Array<String>>? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0)
override fun isContentEncoded() = true
fun cachedPrivateTags(): Array<Array<String>>? = privateTagsCache
fun privateTags(
signer: NostrSigner,
onReady: (Array<Array<String>>) -> Unit,
) {
if (content.isEmpty()) {
onReady(emptyArray())
return
}
privateTagsCache?.let {
onReady(it)
return
}
try {
signer.decrypt(content, pubKey) {
privateTagsCache = mapper.readValue<Array<Array<String>>>(it)
privateTagsCache?.let { onReady(it) }
}
} catch (e: Throwable) {
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
}
}
fun decryptChangeEncrypt(
signer: NostrSigner,
change: (Array<Array<String>>) -> Array<Array<String>>,
onReady: (content: String) -> Unit,
) {
privateTags(signer) { privateTags ->
encryptTags(
privateTags = change(privateTags),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags)
}
}
}
companion object {
fun add(
current: PrivateTagArrayEvent,
newTag: Array<String>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (toPrivate) {
current.privateTags(signer) { privateTags ->
encryptTags(
privateTags = privateTags.plus(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
} else {
onReady(current.content, current.tags.plus(newTag))
}
}
fun addAll(
current: PrivateTagArrayEvent,
newTag: Array<Array<String>>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (toPrivate) {
current.privateTags(signer) { privateTags ->
encryptTags(
privateTags = privateTags.plus(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
} else {
onReady(current.content, current.tags.plus(newTag))
}
}
fun replaceAllToPrivateNewTag(
dTag: String,
current: PrivateTagArrayEvent?,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (current == null) {
createPrivate(dTag, newTag, signer, onReady)
} else {
replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer, onReady)
}
}
fun replaceAllToPublicNewTag(
dTag: String,
current: PrivateTagArrayEvent?,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (current == null) {
createPublic(dTag, newTag, signer, onReady)
} else {
replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer, onReady)
}
}
fun replaceAllToPrivateNewTag(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
encryptTags(
privateTags = privateTags.replaceAll(oldTagStartsWith, newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith))
}
}
}
fun replaceAllToPublicNewTag(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
encryptTags(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag))
}
}
}
fun removeAllFromPrivate(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
encryptTags(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
}
fun removeAllFromPublic(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) = onReady(current.content, current.tags.remove(oldTagStartsWith))
fun removeAll(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
encryptTags(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith))
}
}
}
fun createPrivate(
dTag: String,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
encryptTags(
privateTags = arrayOf(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, arrayOf(arrayOf("d", dTag)))
}
}
fun createPublic(
dTag: String,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
onReady("", arrayOf(arrayOf("d", dTag), newTag))
}
fun encryptTags(
privateTags: Array<Array<String>>? = null,
signer: NostrSigner,
onReady: (String) -> Unit,
) = signer.nip04Encrypt(
if (privateTags.isNullOrEmpty()) "" else mapper.writeValueAsString(privateTags),
signer.pubKey,
onReady,
)
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -38,8 +39,6 @@ class ProfileGalleryEntryEvent(
fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] }
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
@@ -48,7 +47,7 @@ class ProfileGalleryEntryEvent(
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) }
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
@@ -89,12 +88,11 @@ class ProfileGalleryEntryEvent(
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
@@ -112,12 +110,11 @@ class ProfileGalleryEntryEvent(
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION),
hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it) },
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
@@ -0,0 +1,82 @@
/**
* Copyright (c) 2024 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.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class RelationshipStatusEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30382
const val ALT = "Relationship Status"
const val PETNAME = "petname"
const val SUMMARY = "summary"
private fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (RelationshipStatusEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + arrayOf("alt", ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
fun create(
targetUser: HexKey,
petname: String? = null,
summary: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (RelationshipStatusEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
tags.add(arrayOf("d", targetUser))
tags.add(arrayOf("alt", ALT))
val privateTags = mutableListOf<Array<String>>()
petname?.let { privateTags.add(arrayOf(PETNAME, it)) }
summary?.let { privateTags.add(arrayOf(SUMMARY, it)) }
encryptTags(privateTags.toTypedArray(), signer) { content ->
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
}
}
}
}
@@ -25,6 +25,7 @@ import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -56,7 +57,7 @@ class TextNoteEvent(
root: String? = null,
directMentions: Set<HexKey> = emptySet(),
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
forkedFrom: Event? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
@@ -122,12 +123,8 @@ class TextNoteEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
if (isDraft) {
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -61,7 +62,7 @@ class TorrentCommentEvent(
directMentions: Set<HexKey> = emptySet(),
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
forkedFrom: Event? = null,
isDraft: Boolean,
onReady: (TorrentCommentEvent) -> Unit,
@@ -122,12 +123,8 @@ class TorrentCommentEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", ALT))
@@ -21,7 +21,9 @@
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments.Companion.IMETA
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -34,53 +36,75 @@ abstract class VideoEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig),
RootScope {
private fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] }
private fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
private fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
private fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
private fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
private fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) }
private fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
private fun image() = tags.filter { it.size > 1 && it[0] == IMAGE }.map { it[1] }
private fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1)
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
fun title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1)
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == SUMMARY }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == IMAGE }?.get(1)
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1)
fun duration() = tags.firstOrNull { it.size > 1 && it[0] == DURATION }?.get(1)
fun hasUrl() = tags.any { it.size > 1 && it[0] == URL }
fun isOneOf(mimeTypes: Set<String>) = tags.any { it.size > 1 && it[0] == FileHeaderEvent.MIME_TYPE && mimeTypes.contains(it[1]) }
// hack to fix pablo's bug
fun rootVideo() =
url()?.let {
VideoMeta(
url = it,
mimeType = mimeType(),
blurhash = blurhash(),
alt = alt(),
hash = hash(),
dimension = dimensions(),
size = size()?.toIntOrNull(),
service = null,
fallback = emptyList(),
image = image(),
)
}
fun imetaTags() =
tags
.map { tagArray ->
if (tagArray.size > 1 && tagArray[0] == IMETA) {
VideoMeta.parse(tagArray)
} else {
null
}
}.plus(rootVideo())
.filterNotNull()
companion object {
private const val URL = "url"
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
private const val ORIGINAL_HASH = "ox"
private const val ALT = "alt"
private const val TITLE = "title"
private const val PUBLISHED_AT = "published_at"
private const val SUMMARY = "summary"
private const val DURATION = "duration"
private const val IMAGE = "image"
@@ -88,49 +112,131 @@ abstract class VideoEvent(
fun <T : VideoEvent> create(
kind: Int,
dTag: String,
url: String,
magnetUri: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
size: Int? = null,
duration: Int? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
service: String? = null,
altDescription: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (T) -> Unit,
) {
val tags =
listOfNotNull(
arrayOf(URL, url),
magnetUri?.let { arrayOf(MAGNET_URI, it) },
mimeType?.let { arrayOf(MIME_TYPE, it) },
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", altDescription),
hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
} else {
null
}
},
val video =
VideoMeta(
url,
mimeType,
blurhash,
dimensions,
alt,
hash,
size,
service,
emptyList(),
emptyList(),
)
val tags = mutableListOf<Array<String>>()
tags.add(arrayOf("d", dTag))
tags.add(arrayOf(ALT, altDescription))
if (sensitiveContent == true) {
tags.add(arrayOf("content-warning", ""))
}
duration?.let { tags.add(arrayOf(DURATION, "duration")) }
tags.add(video.toIMetaArray())
val content = alt ?: ""
signer.sign<T>(createdAt, kind, tags.toTypedArray(), content, onReady)
}
}
}
data class VideoMeta(
val url: String,
val mimeType: String?,
val blurhash: String?,
val dimension: Dimension?,
val alt: String?,
val hash: String?,
val size: Int?,
val service: String?,
val fallback: List<String>,
val image: List<String>,
) {
fun toIMetaArray(): Array<String> =
(
listOfNotNull(
"imeta",
"$URL $url",
mimeType?.let { "$MIME_TYPE $it" },
alt?.let { "$ALT $it" },
hash?.let { "$HASH $it" },
size?.let { "$FILE_SIZE $it" },
dimension?.let { "$DIMENSION $it" },
blurhash?.let { "$BLUR_HASH $it" },
service?.let { "$SERVICE $it" },
) +
fallback.map { "$FALLBACK $it" } +
image.map { "$IMAGE $it" }
).toTypedArray()
companion object {
const val URL = "url"
const val MIME_TYPE = "m"
const val FILE_SIZE = "size"
const val DIMENSION = "dim"
const val HASH = "x"
const val BLUR_HASH = "blurhash"
const val ALT = "alt"
const val FALLBACK = "fallback"
const val IMAGE = "image"
const val SERVICE = "service"
fun parse(tagArray: Array<String>): VideoMeta? {
var url: String? = null
var mimeType: String? = null
var blurhash: String? = null
var dim: Dimension? = null
var alt: String? = null
var hash: String? = null
var size: Int? = null
var service: String? = null
val fallback = mutableListOf<String>()
val images = mutableListOf<String>()
tagArray.forEach {
val parts = it.split(" ", limit = 2)
val key = parts[0]
val value = if (parts.size == 2) parts[1] else ""
if (value.isNotBlank()) {
when (key) {
URL -> url = value
MIME_TYPE -> mimeType = value
BLUR_HASH -> blurhash = value
DIMENSION -> dim = Dimension.parse(value)
ALT -> alt = value
HASH -> hash = value
FILE_SIZE -> size = value.toIntOrNull()
SERVICE -> service = value
FALLBACK -> fallback.add(value)
IMAGE -> images.add(value)
}
}
}
return url?.let {
VideoMeta(it, mimeType, blurhash, dim, alt, hash, size, service, fallback, images)
}
}
}
}
@@ -21,9 +21,11 @@
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import java.util.UUID
@Immutable
class VideoHorizontalEvent(
@@ -33,48 +35,45 @@ class VideoHorizontalEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
companion object {
const val KIND = 34235
const val ALT_DESCRIPTION = "Horizontal Video"
fun create(
url: String,
magnetUri: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
size: Int? = null,
duration: Int? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
service: String? = null,
dTag: String = UUID.randomUUID().toString(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (VideoHorizontalEvent) -> Unit,
) {
create(
KIND,
url,
magnetUri,
mimeType,
alt,
hash,
size,
dimensions,
blurhash,
originalHash,
magnetURI,
torrentInfoHash,
encryptionKey,
sensitiveContent,
ALT_DESCRIPTION,
signer,
createdAt,
onReady,
kind = KIND,
dTag = dTag,
url = url,
mimeType = mimeType,
alt = alt,
hash = hash,
size = size,
duration = duration,
dimensions = dimensions,
blurhash = blurhash,
sensitiveContent = sensitiveContent,
service = service,
altDescription = ALT_DESCRIPTION,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
@@ -21,9 +21,11 @@
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import java.util.UUID
@Immutable
class VideoVerticalEvent(
@@ -33,48 +35,45 @@ class VideoVerticalEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
companion object {
const val KIND = 34236
const val ALT_DESCRIPTION = "Vertical Video"
fun create(
url: String,
magnetUri: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
size: Int? = null,
duration: Int? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
service: String? = null,
dTag: String = UUID.randomUUID().toString(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (VideoVerticalEvent) -> Unit,
) {
create(
KIND,
url,
magnetUri,
mimeType,
alt,
hash,
size,
dimensions,
blurhash,
originalHash,
magnetURI,
torrentInfoHash,
encryptionKey,
sensitiveContent,
ALT_DESCRIPTION,
signer,
createdAt,
onReady,
kind = KIND,
dTag = dTag,
url = url,
mimeType = mimeType,
alt = alt,
hash = hash,
size = size,
duration = duration,
dimensions = dimensions,
blurhash = blurhash,
sensitiveContent = sensitiveContent,
service = service,
altDescription = ALT_DESCRIPTION,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
@@ -38,7 +38,7 @@ class WikiNoteEvent(
AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
override fun address(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint)
override fun addressTag() = ATag.assembleATag(kind, pubKey, dTag())
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2024 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.utils
public fun removeTrailingNullsAndEmptyOthers(vararg elements: String?): Array<String> {
val lastNonNullIndex = elements.indexOfLast { it != null }
if (lastNonNullIndex < 0) return Array(0) { "" }
return Array(lastNonNullIndex + 1) { index ->
elements[index] ?: ""
}
}
fun Array<String>.startsWith(startsWith: Array<String>): Boolean {
if (startsWith.size > this.size) return false
for (tagIdx in startsWith.indices) {
if (startsWith[tagIdx] != this[tagIdx]) return false
}
return true
}
inline fun Array<Array<String>>.filterToArray(predicate: (Array<String>) -> Boolean): Array<Array<String>> = filterTo(ArrayList(), predicate).toTypedArray()
inline fun Array<Array<String>>.remove(predicate: (Array<String>) -> Boolean): Array<Array<String>> = filterNotTo(ArrayList(this.size), predicate).toTypedArray()
inline fun Array<Array<String>>.remove(startsWith: Array<String>): Array<Array<String>> = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).toTypedArray()
inline fun Array<Array<String>>.replaceAll(
startsWith: Array<String>,
newElement: Array<String>,
): Array<Array<String>> = filterNotTo(ArrayList(this.size), { it.startsWith(startsWith) }).plusElement(newElement).toTypedArray()