This commit is contained in:
Vitor Pamplona
2024-11-19 15:36:30 -05:00
parent 248848799c
commit 4db6f5531c
23 changed files with 791 additions and 573 deletions
@@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent
class Nip92MediaAttachments {
companion object {
private const val IMETA = "imeta"
const val IMETA = "imeta"
}
fun convertFromFileHeader(header: FileHeaderEvent): Array<String>? {
@@ -63,13 +63,22 @@ class Nip92MediaAttachments {
.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 ""
}
}
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 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 ""
}
}
}
@@ -87,6 +87,33 @@ class CommentEvent(
.reversed()
.toTypedArray()
fun firstReplyToEvent(
msg: String,
replyingTo: EventHint<Event>,
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
nip94attachments: List<FileHeaderEvent>? = 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.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, nip94attachments, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
}
fun replyComment(
msg: String,
replyingTo: EventHint<CommentEvent>,
@@ -118,6 +118,7 @@ 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)
@@ -46,7 +46,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)
@@ -44,7 +44,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)
@@ -0,0 +1,322 @@
/**
* 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.HexKey
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments.Companion.IMETA
import com.vitorpamplona.quartz.encoders.PTag
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.MAGNET_URI
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.TORRENT_INFOHASH
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.URL
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.coroutines.cancellation.CancellationException
@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) {
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)
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] }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == FileHeaderEvent.MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == FileHeaderEvent.HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.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)?.let { Dimension.parse(it) }
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 hasUrl() = tags.any { it.size > 1 && it[0] == 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 = "Picture"
private const val MIME_TYPE = "m"
private const val HASH = "x"
private const val TITLE = "title"
private fun create(
msg: String,
tags: MutableList<Array<String>>,
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
nip94attachments: List<FileHeaderEvent>? = null,
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
isDraft: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PictureEvent) -> 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)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
}
if (isDraft) {
signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), msg, onReady)
} else {
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>,
) {
companion object {
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("blurhash") || tagArray[1].contains("size"))) {
// hack to fix pablo's bug
val keys = setOf("url", "m", "blurhash", "dim", "alt", "x", "size", "fallback", "annotate-user")
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(" ")
"m" -> mimeType = values.joinToString(" ")
"blurhash" -> blurhash = values.joinToString(" ")
"dim" -> dim = Dimension.parse(values.joinToString(" "))
"alt" -> alt = values.joinToString(" ")
"x" -> hash = values.joinToString(" ")
"size" -> size = values.joinToString(" ").toLongOrNull()
"fallback" -> fallback.add(values.joinToString(" "))
"annotate-user" -> {
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(" ")
"m" -> mimeType = values.joinToString(" ")
"blurhash" -> blurhash = values.joinToString(" ")
"dim" -> dim = Dimension.parse(values.joinToString(" "))
"alt" -> alt = values.joinToString(" ")
"x" -> hash = values.joinToString(" ")
"size" -> size = values.joinToString(" ").toLongOrNull()
"fallback" -> fallback.add(values.joinToString(" "))
"annotate-user" -> {
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
"m" -> mimeType = value
"blurhash" -> blurhash = value
"dim" -> dim = Dimension.parse(value)
"alt" -> alt = value
"x" -> hash = value
"size" -> size = value.toLongOrNull()
"fallback" -> fallback.add(value)
"annotate-user" -> {
UserAnnotation.parse(value)?.let {
annotations.add(it)
}
}
}
}
}
}
return url?.let {
PictureMeta(it, mimeType, blurhash, dim, alt, hash, size, fallback, annotations)
}
}
}
}
class Dimension(
val width: Int,
val height: Int,
) {
fun aspectRatio() = width.toFloat() / height.toFloat()
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) {
if (e is CancellationException) throw e
null
}
}
}
}
class UserAnnotation(
val pubkey: HexKey,
val x: Int,
val y: Int,
) {
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
}
}
}
@@ -46,7 +46,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)
@@ -87,7 +87,7 @@ 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,
@@ -109,7 +109,7 @@ 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) },
@@ -47,7 +47,7 @@ abstract class VideoEvent(
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)