Adds support to reply to Yaks with another voice message.

This commit is contained in:
Vitor Pamplona
2025-07-29 20:07:28 -04:00
parent c9de32c3fa
commit 8abba9246c
30 changed files with 1899 additions and 10 deletions
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
data class AudioMeta(
val url: String,
val mimeType: String? = null,
val hash: String? = null,
val duration: Int? = null,
val waveform: List<Int>? = null,
@@ -33,6 +34,7 @@ data class AudioMeta(
fun toIMetaArray(): Array<String> =
IMetaTagBuilder(url)
.apply {
mimeType?.let { mimeType(it) }
hash?.let { hash(it) }
duration?.let { duration(it) }
waveform?.let { waveform(it) }
@@ -42,10 +44,11 @@ data class AudioMeta(
companion object {
fun parse(iMeta: IMetaTag): AudioMeta =
AudioMeta(
iMeta.url,
iMeta.hash()?.firstOrNull(),
iMeta.duration()?.firstOrNull()?.toIntOrNull(),
iMeta.waveform()?.firstOrNull()?.let { WaveformTag.parseWave(it) },
url = iMeta.url,
mimeType = iMeta.mimeType()?.firstOrNull(),
hash = iMeta.hash()?.firstOrNull(),
duration = iMeta.duration()?.firstOrNull()?.toIntOrNull(),
waveform = iMeta.waveform()?.firstOrNull()?.let { WaveformTag.parseWave(it) },
)
}
}
@@ -50,6 +50,7 @@ open class BaseVoiceEvent(
initializer: TagArrayBuilder<T>.() -> Unit = {},
) = eventTemplate(kind, voiceMessage.url, createdAt) {
alt(alt)
audioIMeta(voiceMessage)
initializer()
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
/**
@@ -34,3 +35,5 @@ fun IMetaTagBuilder.hash(hash: HexKey) = add(HashSha256Tag.TAG_NAME, hash)
fun IMetaTagBuilder.duration(size: Int) = add(DurationTag.TAG_NAME, size.toString())
fun IMetaTagBuilder.waveform(wave: List<Int>) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWave(wave))
fun IMetaTagBuilder.mimeType(mime: String) = add(MimeTypeTag.TAG_NAME, mime)
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nipA0VoiceMessages
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.DurationTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.HashSha256Tag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.MimeTypeTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag
fun IMetaTag.hash() = properties.get(HashSha256Tag.TAG_NAME)
@@ -30,3 +31,5 @@ fun IMetaTag.hash() = properties.get(HashSha256Tag.TAG_NAME)
fun IMetaTag.duration() = properties.get(DurationTag.TAG_NAME)
fun IMetaTag.waveform() = properties.get(WaveformTag.TAG_NAME)
fun IMetaTag.mimeType() = properties.get(MimeTypeTag.TAG_NAME)
@@ -20,15 +20,21 @@
*/
package com.vitorpamplona.quartz.nipA0VoiceMessages
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag
fun <T : BaseVoiceEvent> TagArrayBuilder<T>.audioIMeta(
url: String,
mimeType: String? = null,
hash: String? = null,
duration: Int? = null,
waveform: List<Int>? = null,
) = audioIMeta(AudioMeta(url, hash, duration, waveform))
) = audioIMeta(AudioMeta(url, mimeType, hash, duration, waveform))
fun <T : BaseVoiceEvent> TagArrayBuilder<T>.audioIMeta(imeta: AudioMeta): TagArrayBuilder<T> {
add(imeta.toIMetaArray())
@@ -40,3 +46,18 @@ fun <T : BaseVoiceEvent> TagArrayBuilder<T>.audioIMeta(audioUrls: List<AudioMeta
audioUrls.forEach { audioIMeta(it) }
return this
}
fun TagArrayBuilder<VoiceReplyEvent>.replyEvent(
eventId: String,
relayHint: NormalizedRelayUrl?,
pubkey: String?,
) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey))
fun TagArrayBuilder<VoiceReplyEvent>.replyKind(kind: String) = addUnique(ReplyKindTag.assemble(kind))
fun TagArrayBuilder<VoiceReplyEvent>.replyKind(kind: Int) = addUnique(ReplyKindTag.assemble(kind))
fun TagArrayBuilder<VoiceReplyEvent>.replyAuthor(
pubKey: HexKey,
relay: NormalizedRelayUrl?,
) = add(ReplyAuthorTag.assemble(pubKey, relay))
@@ -38,6 +38,14 @@ class VoiceEvent(
const val KIND = 1222
const val ALT_DESCRIPTION = "Voice message"
fun build(
url: String,
mimeType: String?,
hash: String,
duration: Int,
waveform: List<Int>,
) = build(AudioMeta(url, mimeType, hash, duration, waveform))
fun build(
voiceMessage: AudioMeta,
createdAt: Long = TimeUtils.now(),
@@ -23,7 +23,12 @@ package com.vitorpamplona.quartz.nipA0VoiceMessages
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull
@Immutable
class VoiceReplyEvent(
@@ -34,14 +39,45 @@ class VoiceReplyEvent(
content: String,
sig: HexKey,
) : BaseVoiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun replyAuthor() = tags.firstNotNullOfOrNull(ReplyAuthorTag::parse)
fun replyAuthors() = tags.filter(ReplyAuthorTag::match)
fun replyAuthorKeys() = tags.mapNotNull(ReplyAuthorTag::parseKey)
fun replyAuthorHints() = tags.mapNotNull(ReplyAuthorTag::parseAsHint)
fun directReplies() = tags.filter { ReplyEventTag.match(it) }
fun directKinds() = tags.filter(ReplyKindTag::match)
fun markedReplyTos(): List<HexKey> = tags.mapNotNull(ReplyEventTag::parseKey)
fun replyingTo(): HexKey? = tags.lastNotNullOfOrNull(ReplyEventTag::parseKey)
companion object {
const val KIND = 1244
const val ALT_DESCRIPTION = "Voice reply"
fun build(
url: String,
mimeType: String?,
hash: String,
duration: Int,
waveform: List<Int>,
replyingTo: EventHintBundle<VoiceEvent>,
) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo)
fun build(
voiceMessage: AudioMeta,
replyingTo: EventHintBundle<VoiceEvent>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<VoiceReplyEvent>.() -> Unit = {},
) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt, initializer)
) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) {
replyEvent(replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey)
replyKind(replyingTo.event.kind)
replyAuthor(replyingTo.event.pubKey, replyingTo.authorHomeRelay)
initializer()
}
}
}
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipA0VoiceMessages.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class MimeTypeTag {
companion object {
const val TAG_NAME = "m"
fun isIn(
tag: Array<String>,
mimeTypes: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in mimeTypes
@JvmStatic
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@JvmStatic
fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType)
}
}
@@ -0,0 +1,85 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipA0VoiceMessages.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@Immutable
data class ReplyAuthorTag(
override val pubKey: HexKey,
override val relayHint: NormalizedRelayUrl? = null,
) : PubKeyReferenceTag {
fun toTagArray() = assemble(pubKey, relayHint)
companion object {
const val TAG_NAME = "p"
@JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun parse(tag: Tag): ReplyAuthorTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
return ReplyAuthorTag(tag[1], hint)
}
@JvmStatic
fun parseKey(tag: Tag): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
@JvmStatic
fun parseAsHint(tag: Array<String>): PubKeyHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val hint = RelayUrlNormalizer.normalizeOrNull(tag[2])
ensure(hint != null) { return null }
return PubKeyHint(tag[1], hint)
}
@JvmStatic
fun assemble(
pubkey: HexKey,
relayHint: NormalizedRelayUrl?,
) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url)
}
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipA0VoiceMessages.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@Immutable
class ReplyEventTag(
val ref: EventReference,
) {
constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint))
fun toTagArray() = assemble(ref)
companion object {
const val TAG_NAME = "e"
@JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun isTagged(
tag: Array<String>,
eventId: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
@JvmStatic
fun isIn(
tag: Array<String>,
eventIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds
@JvmStatic
fun parse(tag: Array<String>): ReplyEventTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return ReplyEventTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, tag.getOrNull(3))
}
@JvmStatic
fun parseKey(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
@JvmStatic
fun parseValidKey(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(Hex.isHex(tag[1])) { return null }
return tag[1]
}
@JvmStatic
fun parseAsHint(tag: Array<String>): EventIdHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])
ensure(relayHint != null) { return null }
return EventIdHint(tag[1], relayHint)
}
@JvmStatic
fun assemble(
eventId: HexKey,
relay: NormalizedRelayUrl?,
pubkey: String?,
) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey)
@JvmStatic
fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author)
}
}
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipA0VoiceMessages.tags
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
class ReplyKindTag {
companion object {
const val TAG_NAME = "k"
@JvmStatic
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@JvmStatic
fun isKind(
tag: Tag,
kind: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind
@JvmStatic
fun isTagged(
tag: Tag,
kind: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == kind
@JvmStatic
fun isIn(
tag: Tag,
kinds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in kinds
@JvmStatic
fun parse(tag: Tag): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@JvmStatic
fun assemble(kind: String) = arrayOf(TAG_NAME, kind)
@JvmStatic
fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString())
@JvmStatic
fun assemble(id: ExternalId) = assemble(id.toKind())
@JvmStatic
fun assemble(kinds: List<String>): List<Tag> = kinds.map { assemble(it) }
@JvmStatic
fun assemble(kinds: Set<String>): List<Tag> = kinds.map { assemble(it) }
}
}