Merge branch 'vitorpamplona:main' into profiles-list-management

This commit is contained in:
KotlinGeekDev
2025-08-16 01:34:30 +00:00
committed by GitHub
45 changed files with 888 additions and 594 deletions
@@ -122,8 +122,6 @@ class NostrClient(
}
}
fun allAvailableRelays() = relayPool.getAll()
// Reconnects all relays that may have disconnected
fun connect() {
isActive = true
@@ -138,10 +136,7 @@ class NostrClient(
@Synchronized
fun reconnect(onlyIfChanged: Boolean = false) {
if (onlyIfChanged) {
relayPool.getAllNeedsToReconnect().forEach {
it.disconnect()
}
relayPool.connect()
relayPool.reconnectIfNeedsToORIfItIsTime()
} else {
relayPool.disconnect()
relayPool.connect()
@@ -236,6 +231,9 @@ class NostrClient(
relayPool.connectIfDisconnected(relay)
}
}
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
}
}
@@ -269,6 +267,9 @@ class NostrClient(
relayPool.connectIfDisconnected(relay)
}
}
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
}
}
@@ -278,6 +279,9 @@ class NostrClient(
) {
if (isActive) {
relayPool.getRelay(connectedRelay)?.send(event)
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
}
}
@@ -288,6 +292,9 @@ class NostrClient(
eventOutbox.markAsSending(event, relayList)
if (isActive) {
relayPool.send(event, relayList)
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
}
}
@@ -0,0 +1,60 @@
/**
* 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.nip01Core.relay.client
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.RandomInstance
class NostrClientSubscription(
val client: NostrClient,
val filter: () -> Map<NormalizedRelayUrl, List<Filter>> = { emptyMap() },
val onEvent: (event: Event) -> Unit = {},
) : IRelayClientListener {
private val subId = RandomInstance.randomChars(10)
init {
client.subscribe(this)
}
override fun onEvent(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
) {
if (this.subId == subId) {
onEvent(event)
}
}
/**
* Creates or Updates the filter with relays. This method should be called
* everytime the filter changes.
*/
fun updateFilter() = client.openReqSubscription(subId, filter())
fun closeSubscription() = client.close(subId)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.LargeCache
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -70,16 +71,24 @@ class RelayPool(
fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url)
fun getAll() = statusFlow.value.connected
var lastReconnectCall = TimeUtils.now()
fun getAllNeedsToReconnect() = relays.filter { url, relay -> relay.needsToReconnect() }
fun reconnectsRelaysThatNeedTo() {
relays.forEach { url, relay ->
if (relay.needsToReconnect()) {
relay.disconnect()
relay.connect()
fun reconnectIfNeedsToORIfItIsTime() {
if (lastReconnectCall < TimeUtils.tenSecondsAgo()) {
relays.forEach { url, relay ->
if (relay.isConnected()) {
if (relay.needsToReconnect()) {
// network has changed, force reconnect
relay.disconnect()
relay.connect()
}
} else {
// relay is not connected. Connect if it is time
relay.connectAndSyncFiltersIfDisconnected()
}
}
lastReconnectCall = TimeUtils.now()
}
}
@@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.Companion.DELAY_TO_RECONNECT_IN_MSECS
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
@@ -84,7 +83,7 @@ open class BasicRelayClient(
) : IRelayClient {
companion object {
// waits 3 minutes to reconnect once things fail
const val DELAY_TO_RECONNECT_IN_MSECS = 500L
const val DELAY_TO_RECONNECT_IN_SECS = 1
const val EVENT_MESSAGE_PREFIX = "[\"${EventMessage.LABEL}\""
}
@@ -94,8 +93,8 @@ open class BasicRelayClient(
private var isReady: Boolean = false
private var usingCompression: Boolean = false
private var lastConnectTentative: Long = 0L // the beginning of time.
private var delayToConnect = DELAY_TO_RECONNECT_IN_MSECS
private var lastConnectTentativeInSeconds: Long = 0L // the beginning of time.
private var delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
@@ -138,7 +137,7 @@ open class BasicRelayClient(
Log.d(logTag, "Connecting...")
lastConnectTentative = TimeUtils.now()
lastConnectTentativeInSeconds = TimeUtils.now()
socket = socketBuilder.build(url, MyWebsocketListener(onConnected))
socket?.connect()
@@ -207,22 +206,30 @@ open class BasicRelayClient(
code: Int?,
response: String?,
) {
socket?.disconnect() // 1000, "Normal close"
// socket is already closed
// socket?.disconnect()
// checks if this is an actual failure. Closing the socket generates an onFailure as well.
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) {
stats.newError(response ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}")
if (socket == null) {
// comes from the disconnect action.
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
} else {
// checks if this is an actual failure. Closing the socket generates an onFailure as well.
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) {
stats.newError(response ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}")
}
// Failures disconnect the relay.
markConnectionAsClosed()
Log.w(logTag, "OnFailure $code $response ${t.message}")
if (code == 403 || code == 502 || code == 503 || code == 402 || code == 410 || t.message == "SOCKS: Host unreachable") {
dontTryAgainForALongTime()
}
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
listener.onError(this@BasicRelayClient, "", Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t))
}
// Failures disconnect the relay.
markConnectionAsClosed()
Log.w(logTag, "OnFailure $code $response ${t.message} $socket")
listener.onError(
this@BasicRelayClient,
"",
Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t),
)
}
}
@@ -261,7 +268,7 @@ open class BasicRelayClient(
this.usingCompression = usingCompression
// resets any extra delays added during on offline state
this.delayToConnect = DELAY_TO_RECONNECT_IN_MSECS
this.delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
stats.pingInMs = pingInMs
}
@@ -320,7 +327,7 @@ open class BasicRelayClient(
}
private fun processAuth(msg: AuthMessage) {
// Log.d(logTag, "Auth $newMessage")
Log.d(logTag, "Auth ${msg.challenge}")
listener.onAuth(this@BasicRelayClient, msg.challenge)
}
@@ -344,8 +351,8 @@ open class BasicRelayClient(
override fun disconnect() {
Log.d(logTag, "Disconnecting...")
lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested.
delayToConnect = DELAY_TO_RECONNECT_IN_MSECS
lastConnectTentativeInSeconds = 0L // this is not an error, so prepare to reconnect as soon as requested.
delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
socket?.disconnect()
socket = null
isReady = false
@@ -372,12 +379,7 @@ open class BasicRelayClient(
}
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > lastConnectTentative + delayToConnect) {
delayToConnect = delayToConnect * 2
// sends all filters after connection is successful.
connect()
}
connectAndSyncFiltersIfDisconnected()
}
}
@@ -393,25 +395,30 @@ open class BasicRelayClient(
}
}
} else {
connectAndSyncFiltersIfDisconnected()
}
}
override fun connectAndSyncFiltersIfDisconnected() {
if (!isConnectionStarted() && !connectingMutex.get()) {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > lastConnectTentative + delayToConnect) {
// sends all filters after connection is successful.
delayToConnect = delayToConnect * 2
if (TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) {
upRelayDelayToConnect()
connect()
}
}
}
override fun connectAndSyncFiltersIfDisconnected() {
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > lastConnectTentative + delayToConnect) {
delayToConnect = delayToConnect * 2
connect()
}
fun upRelayDelayToConnect() {
if (delayToConnectInSeconds < TimeUtils.FIVE_MINUTES) {
delayToConnectInSeconds = delayToConnectInSeconds * 2
}
}
fun dontTryAgainForALongTime() {
delayToConnectInSeconds = TimeUtils.ONE_DAY
}
override fun send(event: Event) {
listener.onBeforeSend(this@BasicRelayClient, event)
@@ -439,8 +446,7 @@ open class BasicRelayClient(
writeToSocket(EventCmd.Companion.toJson(event))
}
} else {
// automatically sends all filters after connection is successful.
connect()
connectAndSyncFiltersIfDisconnected()
}
}
@@ -29,9 +29,6 @@ class NoticeMessage(
const val LABEL = "NOTICE"
@JvmStatic
fun parse(msgArray: JsonNode): NoticeMessage =
NoticeMessage(
msgArray.get(1).asText(),
)
fun parse(msgArray: JsonNode) = NoticeMessage(msgArray.get(1).asText())
}
}
@@ -36,7 +36,7 @@ fun NormalizedRelayUrl.toHttp() =
if (url.startsWith("wss://")) {
"https${url.drop(3)}"
} else if (url.startsWith("ws://")) {
"https${url.drop(2)}"
"http${url.drop(2)}"
} else {
"https://$url"
}
@@ -0,0 +1,96 @@
/**
* 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.nip01Core.relay.sockets.okhttp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class BasicOkHttpWebSocket(
val url: NormalizedRelayUrl,
val httpClient: OkHttpClient,
val out: WebSocketListener,
) : WebSocket {
private var socket: okhttp3.WebSocket? = null
override fun needsReconnect() = socket == null
override fun connect() {
val request = Request.Builder().url(url.url).build()
val listener =
object : okhttp3.WebSocketListener() {
override fun onOpen(
webSocket: okhttp3.WebSocket,
response: Response,
) = out.onOpen(
response.receivedResponseAtMillis - response.sentRequestAtMillis,
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
)
override fun onMessage(
webSocket: okhttp3.WebSocket,
text: String,
) = out.onMessage(text)
override fun onClosing(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) = out.onClosing(code, reason)
override fun onClosed(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) = out.onClosed(code, reason)
override fun onFailure(
webSocket: okhttp3.WebSocket,
t: Throwable,
r: Response?,
) = out.onFailure(t, r?.code, r?.message)
}
socket = httpClient.newWebSocket(request, listener)
}
override fun disconnect() {
socket?.cancel()
socket = null
}
override fun send(msg: String): Boolean = socket?.send(msg) ?: false
class Builder(
val httpClient: OkHttpClient,
) : WebsocketBuilder {
// Called when connecting.
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
) = BasicOkHttpWebSocket(url, httpClient, out)
}
}
@@ -0,0 +1,25 @@
/**
* 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.nip01Core.tags.publishedAt
interface PublishedAtProvider {
fun publishedAt(): Long?
}
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
import com.vitorpamplona.quartz.nip19Bech32.addressHints
@@ -68,6 +69,7 @@ class LongTextNoteEvent(
EventHintProvider,
PubKeyHintProvider,
AddressHintProvider,
PublishedAtProvider,
RootScope,
SearchableEvent {
override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content
@@ -130,7 +132,7 @@ class LongTextNoteEvent(
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
companion object {
const val KIND = 30023
@@ -32,7 +32,16 @@ class PublishedAtTag {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
val timestamp = tag[1].toLongOrNull()
if (timestamp == null) return null
if (timestamp > 3_000_000_000) {
// like in milliseconds
return timestamp / 1000
} else {
return timestamp
}
}
@JvmStatic
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
@@ -62,6 +63,7 @@ class WikiNoteEvent(
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider,
PublishedAtProvider,
SearchableEvent {
override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content
@@ -127,7 +129,7 @@ class WikiNoteEvent(
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun publishedAt() =
override fun publishedAt() =
try {
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
} catch (_: Exception) {
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
@@ -45,12 +46,13 @@ abstract class VideoEvent(
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig),
PublishedAtProvider,
RootScope {
@Transient var iMetas: List<VideoMeta>? = null
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse)
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.isTaggedKind
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip31Alts.alt
@@ -46,7 +47,8 @@ class AppDefinitionEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PublishedAtProvider {
override fun countMemory(): Long = super.countMemory() + (cachedMetadata?.countMemory() ?: 8L)
@Transient private var cachedMetadata: AppMetadata? = null
@@ -77,7 +79,7 @@ class AppDefinitionEvent(
fun includeKind(kind: Int) = tags.isTaggedKind(kind)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
companion object {
const val KIND = 31990
@@ -113,8 +113,8 @@ class AppMetadata {
fun toJson() = assemble(this)
companion object {
fun assemble(data: AppMetadata) = JsonMapper.mapper.writeValueAsString(data)
fun assemble(data: AppMetadata): String = JsonMapper.mapper.writeValueAsString(data)
fun parse(content: String) = JsonMapper.mapper.readValue(content, AppMetadata::class.java)
fun parse(content: String): AppMetadata = JsonMapper.mapper.readValue(content, AppMetadata::class.java)
}
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
@@ -49,7 +50,8 @@ class ClassifiedsEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PublishedAtProvider {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
@@ -68,7 +70,7 @@ class ClassifiedsEvent(
fun location() = tags.firstNotNullOfOrNull(LocationTag::parse)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
fun categories() = tags.hashtags()
@@ -36,6 +36,8 @@ object TimeUtils {
fun tenSecondsFromNow() = now() + TEN_SECONDS
fun tenSecondsAgo() = now() - TEN_SECONDS
fun oneMinuteFromNow() = now() + ONE_MINUTE
fun oneMinuteAgo() = now() - ONE_MINUTE