From 0bc0f503be5ef78703d02356455cfa26beb540be Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 Sep 2025 17:34:15 -0400 Subject: [PATCH 01/18] Reduces errors when trying to parse unparseable NIP-11s --- .../amethyst/service/Nip11RelayInfoRetriever.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index d58d29dd3..daf3ef006 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -175,7 +175,11 @@ class Nip11Retriever { val body = response.body.string() try { if (response.isSuccessful) { - onInfo(Nip11RelayInformation.fromJson(body)) + if (body.startsWith("{")) { + onInfo(Nip11RelayInformation.fromJson(body)) + } else { + onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, body) + } } else { onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString()) } From fb5c20d8da20160050cbbdca25e590da0f723856 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 Sep 2025 18:27:46 -0400 Subject: [PATCH 02/18] Abandoning the precision of Mills to seconds on the isOnline Check --- .../amethyst/service/OnlineCheck.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index 04f21d2fd..fee35be22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -24,6 +24,7 @@ import android.util.Log import android.util.LruCache import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils import okhttp3.EventListener import okhttp3.OkHttpClient import okhttp3.Protocol @@ -33,29 +34,37 @@ import okio.ByteString.Companion.toByteString import kotlin.coroutines.cancellation.CancellationException @Immutable data class OnlineCheckResult( - val timeInMs: Long, + val timeInSecs: Long, val online: Boolean, ) object OnlineChecker { val checkOnlineCache = LruCache(100) - val fiveMinutes = 1000 * 60 * 5 fun isOnlineCached(url: String?): Boolean { if (url.isNullOrBlank()) return false - if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { - return checkOnlineCache.get(url).online + val cached = checkOnlineCache.get(url) + if (cached != null && cached.timeInSecs > TimeUtils.fiveMinutesAgo()) { + return cached.online } return false } + fun resetIfOfflineToRetry(url: String) { + val cached = checkOnlineCache.get(url) + if (cached != null && !cached.online) { + checkOnlineCache.remove(url) + } + } + suspend fun isOnline( url: String?, okHttpClient: (String) -> OkHttpClient, ): Boolean { if (url.isNullOrBlank()) return false - if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) { - return checkOnlineCache.get(url).online + val cached = checkOnlineCache.get(url) + if (cached != null && cached.timeInSecs > TimeUtils.fiveMinutesAgo()) { + return cached.online } return try { @@ -89,14 +98,16 @@ object OnlineChecker { .build() val client = okHttpClient(url) - client.newCall(request).executeAsync().use { it.isSuccessful } + client.newCall(request).executeAsync().use { + it.isSuccessful + } } - checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result)) + checkOnlineCache.put(url, OnlineCheckResult(TimeUtils.now(), result)) result } catch (e: Exception) { if (e is CancellationException) throw e - checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false)) + checkOnlineCache.put(url, OnlineCheckResult(TimeUtils.now(), false)) Log.e("LiveActivities", "Failed to check streaming url $url", e) false } From 116bd1ae296a7ee2c042ee3f89cb1ef608bb4c3b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 Sep 2025 18:28:14 -0400 Subject: [PATCH 03/18] Removes any relay url that has the null byte, regardless of size --- .../quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index 1729f4886..c417c8f81 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -94,9 +94,9 @@ class RelayUrlNormalizer { @OptIn(ExperimentalContracts::class) fun fix(url: String): String? { if (url.length < 4) return null - if (url.length > 50) { - if (url.indexOf("%00") > -1) return null + if (url.contains("%00")) return null + if (url.length > 50) { // removes multiple urls in the same line val schemeIdx = url.indexOf("://") val nextScheme = url.indexOf("://", schemeIdx + 3) From 7a807e94af2d4884c095f4a28102dc6abb716dda Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 Sep 2025 18:28:41 -0400 Subject: [PATCH 04/18] Exposes active filters per relay on the Nostr Client for debugging purposes --- .../quartz/nip01Core/relay/client/NostrClient.kt | 7 +++++++ .../relay/client/pool/PoolEventOutboxRepository.kt | 10 ++++++++++ .../relay/client/pool/PoolSubscriptionRepository.kt | 11 +++++++++++ 3 files changed, 28 insertions(+) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index aceb69c39..e464d787f 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client import com.vitorpamplona.quartz.nip01Core.core.Event +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.pool.PoolEventOutboxRepository @@ -395,6 +396,12 @@ class NostrClient( listeners = listeners.minus(listener) } + fun activeRequests(url: NormalizedRelayUrl): Map> = activeRequests.activeFiltersFor(url) + + fun activeCounts(url: NormalizedRelayUrl): Map> = activeCounts.activeFiltersFor(url) + + fun activeOutboxCache(url: NormalizedRelayUrl): Set = eventOutbox.activeOutboxCacheFor(url) + fun getSubscriptionFiltersOrNull(subId: String): Map>? = activeRequests.getSubscriptionFiltersOrNull(subId) fun relayStatusFlow() = relayPool.statusFlow diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt index 8ca1e03c9..8a301109d 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt @@ -40,6 +40,16 @@ class PoolEventOutboxRepository { } } + fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set { + val myEvents = mutableSetOf() + eventOutbox.forEach { eventId, outboxCache -> + if (url in outboxCache.relays) { + myEvents.add(eventId) + } + } + return myEvents + } + fun markAsSending( event: Event, relays: Set, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt index c24064a12..2ac117b69 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt @@ -40,6 +40,17 @@ class PoolSubscriptionRepository { } } + fun activeFiltersFor(url: NormalizedRelayUrl): Map> { + val myRelays = mutableMapOf>() + subscriptions.forEach { sub, perRelayFilters -> + val filters = perRelayFilters.get(url) + if (filters != null) { + myRelays.put(sub, filters) + } + } + return myRelays + } + fun addOrUpdate( subscriptionId: String, filters: Map>, From 480ef63aed29a37153d8661877cc93c439eaf2af Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 Sep 2025 18:30:01 -0400 Subject: [PATCH 05/18] adds a border on the users of a given relay in the connected relay list --- .../loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index ac10deb3e..2b1449936 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding @@ -127,7 +128,7 @@ fun UsedBy( nav: INav, ) { if (item.users.isNotEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(modifier = HalfHalfVertPadding, verticalAlignment = Alignment.CenterVertically) { item.users.getOrNull(0)?.let { UserPicture( user = it, From eef0adc6f9039ccf6baa7b807638c0880185732e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 Sep 2025 18:30:47 -0400 Subject: [PATCH 06/18] Resets streaming url check if offline and entering a chat of that streaming --- .../nip53LiveActivities/LiveActivityChannelScreen.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt index 03d052d8d..4db210440 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53 import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel @@ -45,6 +47,11 @@ fun LiveActivityChannelScreen( isInvertedLayout = true, topBar = { LoadLiveActivityChannel(channelId, accountViewModel) { + LaunchedEffect(it.info) { + it.info?.streaming()?.let { + OnlineChecker.resetIfOfflineToRetry(it) + } + } LiveActivityTopBar(it, accountViewModel, nav) } }, From c46cf795014b7d101df2fc77ed219c6cfc63b3f8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 Sep 2025 18:34:21 -0400 Subject: [PATCH 07/18] Correctly sorts by online status when the app has already cached the status of a streaming --- .../amethyst/service/OnlineCheck.kt | 6 ++++++ .../DiscoverLiveFeedFilter.kt | 19 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index fee35be22..1f406816c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -41,6 +41,12 @@ import kotlin.coroutines.cancellation.CancellationException object OnlineChecker { val checkOnlineCache = LruCache(100) + fun isCachedAndOffline(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val cached = checkOnlineCache.get(url) + return cached != null && !cached.online && cached.timeInSecs > TimeUtils.fiveMinutesAgo() + } + fun isOnlineCached(url: String?): Boolean { if (url.isNullOrBlank()) return false val cached = checkOnlineCache.get(url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 118149e56..2b1d0720c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByPr import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent @@ -102,7 +103,7 @@ open class DiscoverLiveFeedFilter( return items .sortedWith( compareBy( - { convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) }, + { convertStatusToOrder((it.event as? LiveActivitiesEvent)) }, { participantCounts[it] }, { allParticipants[it] }, { (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() }, @@ -111,11 +112,21 @@ open class DiscoverLiveFeedFilter( ).reversed() } - fun convertStatusToOrder(status: StatusTag.STATUS?): Int = - when (status) { - StatusTag.STATUS.LIVE -> 2 + fun convertStatusToOrder(event: LiveActivitiesEvent?): Int { + if (event == null) return 0 + val url = event.streaming() + if (url == null) return 0 + return when (event.status()) { + StatusTag.STATUS.LIVE -> { + if (OnlineChecker.isCachedAndOffline(url)) { + 0 + } else { + 2 + } + } StatusTag.STATUS.PLANNED -> 1 StatusTag.STATUS.ENDED -> 0 else -> 0 } + } } From c64e65ddb47da3e290d7e2d5fb3a2739c68dc383 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 Sep 2025 19:20:44 -0400 Subject: [PATCH 08/18] Migrates to use suspending routines for OTS, statuses and edits --- .../vitorpamplona/amethyst/ui/note/Loaders.kt | 15 ++++---- .../amethyst/ui/note/NoteCompose.kt | 23 ++++++------ .../ui/screen/loggedIn/AccountViewModel.kt | 36 ++++++------------- 3 files changed, 28 insertions(+), 46 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 570c04cb0..076e62093 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -129,14 +129,13 @@ fun LoadOts( val noteStatus by observeNoteOts(note, accountViewModel) LaunchedEffect(key1 = noteStatus) { - accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts -> - earliestDate = - if (newOts == null) { - GenericLoadable.Empty() - } else { - GenericLoadable.Loaded(newOts) - } - } + val newOts = accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) + earliestDate = + if (newOts == null) { + GenericLoadable.Empty() + } else { + GenericLoadable.Loaded(newOts) + } } (earliestDate as? GenericLoadable.Loaded)?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 13f4c542f..54de45d75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1230,19 +1230,18 @@ fun observeEdits( LaunchedEffect(key1 = updatedNote) { updatedNote?.note?.let { - accountViewModel.findModificationEventsForNote(it) { newModifications -> - if (newModifications.isEmpty()) { - if (editState.value !is GenericLoadable.Empty) { - editState.value = GenericLoadable.Empty() - } + val newModifications = accountViewModel.findModificationEventsForNote(it) + if (newModifications.isEmpty()) { + if (editState.value !is GenericLoadable.Empty) { + editState.value = GenericLoadable.Empty() + } + } else { + if (editState.value is GenericLoadable.Loaded) { + (editState.value as? GenericLoadable.Loaded)?.loaded?.updateModifications(newModifications) } else { - if (editState.value is GenericLoadable.Loaded) { - (editState.value as? GenericLoadable.Loaded)?.loaded?.updateModifications(newModifications) - } else { - val state = EditState() - state.updateModifications(newModifications) - editState.value = GenericLoadable.Loaded(state) - } + val state = EditState() + state.updateModifications(newModifications) + editState.value = GenericLoadable.Loaded(state) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 100ef3ca7..90a93c8b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1026,38 +1026,22 @@ class AccountViewModel( fun getAddressableNoteIfExists(key: Address): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) - suspend fun findStatusesForUser( - myUser: User, - onResult: (ImmutableList) -> Unit, - ) { + suspend fun findStatusesForUser(myUser: User) = withContext(Dispatchers.IO) { - onResult(LocalCache.findStatusesForUser(myUser)) + LocalCache.findStatusesForUser(myUser) } - } - suspend fun findOtsEventsForNote( - note: Note, - onResult: (Long?) -> Unit, - ) { - onResult( - withContext(Dispatchers.Default) { - LocalCache.findEarliestOtsForNote(note, account.otsResolverBuilder) - }, - ) - } + suspend fun findOtsEventsForNote(note: Note) = + withContext(Dispatchers.Default) { + LocalCache.findEarliestOtsForNote(note, account.otsResolverBuilder) + } fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note) - suspend fun findModificationEventsForNote( - note: Note, - onResult: (List) -> Unit, - ) { - onResult( - withContext(Dispatchers.Default) { - LocalCache.findLatestModificationForNote(note) - }, - ) - } + suspend fun findModificationEventsForNote(note: Note): List = + withContext(Dispatchers.Default) { + LocalCache.findLatestModificationForNote(note) + } fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel? = LocalCache.getOrCreatePublicChatChannel(key) From c9cdc74c91cab73008ad8dae0276820852c5db66 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 Sep 2025 15:44:23 -0400 Subject: [PATCH 09/18] Removes duplicated Loading of Relay Info --- .../service/Nip11RelayInfoRetriever.kt | 54 ++++++++++--------- .../common/BasicRelaySetupInfoDialog.kt | 3 -- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index daf3ef006..ed6eb24ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -56,6 +56,8 @@ object Nip11CachedRetriever { class Empty( data: Nip11RelayInformation, ) : RetrieveResult(data, TimeUtils.now()) + + fun isValid() = time > TimeUtils.oneHourAgo() } private val relayInformationEmptyCache = LruCache(1000) @@ -79,16 +81,18 @@ object Nip11CachedRetriever { fun getFromCache(relay: NormalizedRelayUrl): Nip11RelayInformation { val result = relayInformationDocumentCache.get(relay) + if (result == null) { + // resets the clock + val empty = getEmpty(relay) + relayInformationDocumentCache.put(relay, RetrieveResult.Empty(empty)) + return empty + } + return when (result) { - is RetrieveResult.Success -> return result.data - is RetrieveResult.Error -> return result.data - is RetrieveResult.Empty -> return result.data - is RetrieveResult.Loading -> return result.data - else -> { - val empty = getEmpty(relay) - relayInformationDocumentCache.put(relay, RetrieveResult.Empty(empty)) - empty - } + is RetrieveResult.Success -> result.data + is RetrieveResult.Error -> result.data + is RetrieveResult.Empty -> result.data + is RetrieveResult.Loading -> result.data } } @@ -100,23 +104,23 @@ object Nip11CachedRetriever { ) { val doc = relayInformationDocumentCache.get(relay) if (doc != null) { - if (doc is RetrieveResult.Success) { - onInfo(doc.data) - } else if (doc is RetrieveResult.Loading) { - if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) { - // just wait. - } else { - retrieve(relay, okHttpClient, onInfo, onError) + when (doc) { + is RetrieveResult.Success -> onInfo(doc.data) + is RetrieveResult.Loading -> { + if (doc.isValid()) { + // just wait. + } else { + retrieve(relay, okHttpClient, onInfo, onError) + } } - } else if (doc is RetrieveResult.Error) { - if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) { - onError(relay, doc.error, null) - } else { - retrieve(relay, okHttpClient, onInfo, onError) + is RetrieveResult.Error -> { + if (doc.isValid()) { + onError(relay, doc.error, null) + } else { + retrieve(relay, okHttpClient, onInfo, onError) + } } - } else { - // Empty - retrieve(relay, okHttpClient, onInfo, onError) + is RetrieveResult.Empty -> retrieve(relay, okHttpClient, onInfo, onError) } } else { retrieve(relay, okHttpClient, onInfo, onError) @@ -135,10 +139,12 @@ object Nip11CachedRetriever { okHttpClient = okHttpClient, onInfo = { relayInformationDocumentCache.put(relay, RetrieveResult.Success(it)) + relayInformationEmptyCache.remove(relay) onInfo(it) }, onError = { relay, code, errorMsg -> relayInformationDocumentCache.put(relay, RetrieveResult.Error(getEmpty(relay), code, errorMsg)) + relayInformationEmptyCache.remove(relay) onError(relay, code, errorMsg) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 6ca31d0b1..3d65385a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo @Composable fun BasicRelaySetupInfoDialog( @@ -35,8 +34,6 @@ fun BasicRelaySetupInfoDialog( accountViewModel: AccountViewModel, nav: INav, ) { - val relayInfo by loadRelayInfo(item.relay, accountViewModel) - BasicRelaySetupInfoClickableRow( item = item, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, From 85fb6dcc83da1f8949fda2763d9d0db4bd638fae Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 Sep 2025 16:35:10 -0400 Subject: [PATCH 10/18] Avoids breaking line in the relay info top nav bar title --- .../ui/screen/loggedIn/relays/RelayInformationScreen.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 708e6c4db..9746b5a66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R @@ -108,7 +109,11 @@ fun RelayInformationScreen( TopAppBar( actions = {}, title = { - Text(relay.displayUrl()) + Text( + relay.displayUrl(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) }, navigationIcon = { Row { From 43c9df23c4517561256b043431115833875e8432 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 Sep 2025 16:37:30 -0400 Subject: [PATCH 11/18] Removes common mistakes from the outbox list from follows --- .../amethyst/model/topNavFeeds/OutboxRelayLoader.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt index 2d992980e..f33da000a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt @@ -56,7 +56,13 @@ class OutboxRelayLoader { ?: Constants.eventFinderRelays relays.forEach { - add(it, authorHex) + if (!it.url.startsWith("wss://feeds.nostr.band") && + !it.url.startsWith("wss://filter.nostr.wine") && + !it.url.startsWith("wss://nwc.primal.net") && + !it.url.startsWith("wss://relay.getalby.com") + ) { + add(it, authorHex) + } } } } From e4e52ec4e62d42330e75e2f8d984aee0578b55b6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 Sep 2025 16:51:27 -0400 Subject: [PATCH 12/18] Unify outbox calculation in the RelayOutbox loader. --- .../FollowListOutboxOrProxyRelays.kt | 37 +----- .../nip02FollowLists/FollowsPerOutboxRelay.kt | 50 +------- .../model/topNavFeeds/OutboxRelayLoader.kt | 116 +++++++++--------- .../AllFollowsByOutboxTopNavFilter.kt | 4 +- .../author/AuthorsByOutboxTopNavFilter.kt | 4 +- .../community/SingleCommunityTopNavFilter.kt | 4 +- .../muted/MutedAuthorsByOutboxTopNavFilter.kt | 4 +- 7 files changed, 79 insertions(+), 140 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt index d70ee68a8..44ea6d2a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxOrProxyRelays.kt @@ -20,18 +20,14 @@ */ package com.vitorpamplona.amethyst.model.nip02FollowLists -import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -52,37 +48,16 @@ class FollowListOutboxOrProxyRelays( val cache: LocalCache, scope: CoroutineScope, ) { - fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey) - - fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey)) - - fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow - - fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent - - fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } - - fun combineAllFlows(flows: List>): Flow> = - combine(flows) { relayListNotes: Array -> - relayListNotes - .mapNotNull { - (it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() - }.flatten() - .toSet() - } - @OptIn(ExperimentalCoroutinesApi::class) val outboxRelayFlow: StateFlow> = kind3Follows.flow - .transformLatest { - emitAll(combineAllFlows(allRelayListFlows(it.authors))) + .transformLatest { follows -> + emitAll( + OutboxRelayLoader(true).toAuthorsPerRelayFlow(follows.authors, cache) { it.keys }, + ) }.onStart { emit( - kind3Follows.flow.value.authors - .mapNotNull { - getNIP65RelayList(it)?.writeRelaysNorm() - }.flatten() - .toSet(), + OutboxRelayLoader(true).authorsPerRelaySnapshot(kind3Follows.flow.value.authors, cache) { it.keys }, ) }.distinctUntilChanged() .flowOn(Dispatchers.Default) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt index a00bd2bcc..b81e95124 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt @@ -20,20 +20,15 @@ */ package com.vitorpamplona.amethyst.model.nip02FollowLists -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.utils.mapOfSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -53,51 +48,16 @@ class FollowsPerOutboxRelay( val cache: LocalCache, scope: CoroutineScope, ) { - fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey) - - fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey)) - - fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow - - fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent - - fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } - - fun combineAllRelayListFlows(flows: List>): Flow>> = - combine(flows) { relayListNotes: Array -> - mapOfSet { - relayListNotes.forEach { noteState -> - noteState.note.author?.pubkeyHex?.let { authorHex -> - val outboxRelayList = - getNIP65RelayList(authorHex)?.writeRelaysNorm() - ?: LocalCache.relayHints.hintsForKey(authorHex).ifEmpty { null } - ?: Constants.eventFinderRelays - outboxRelayList.forEach { relay -> - add(relay, authorHex) - } - } - } - } - } - @OptIn(ExperimentalCoroutinesApi::class) val outboxPerRelayFlow: StateFlow>> = kind3Follows.flow .transformLatest { - emitAll(combineAllRelayListFlows(allRelayListFlows(it.authors))) + emitAll( + OutboxRelayLoader().toAuthorsPerRelayFlow(it.authors, cache) { it }, + ) }.onStart { emit( - mapOfSet { - kind3Follows.flow.value.authors.map { authorHex -> - val outboxRelayList = - getNIP65RelayList(authorHex)?.writeRelaysNorm() - ?: LocalCache.relayHints.hintsForKey(authorHex).ifEmpty { null } - ?: Constants.eventFinderRelays - outboxRelayList.forEach { relay -> - add(relay, authorHex) - } - } - }, + OutboxRelayLoader().authorsPerRelaySnapshot(kind3Follows.flow.value.authors, cache) { it }, ) }.distinctUntilChanged() .flowOn(Dispatchers.Default) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt index f33da000a..5b1b6f843 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt @@ -32,75 +32,79 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -class OutboxRelayLoader { - companion object { - private fun authorsPerRelay( - outboxRelayNotes: Array, - cache: LocalCache, - ): Map> = - mapOfSet { - outboxRelayNotes.forEach { outboxNote -> - val note = outboxNote.note +class OutboxRelayLoader( + val rawOutboxRelays: Boolean = false, +) { + fun authorsPerRelay( + outboxRelayNotes: Array, + cache: LocalCache, + ): Map> = + mapOfSet { + outboxRelayNotes.forEach { outboxNote -> + val note = outboxNote.note - val authorHex = - if (note is AddressableNote) { - note.address.pubKeyHex + val authorHex = + if (note is AddressableNote) { + note.address.pubKeyHex + } else { + note.author?.pubkeyHex + } + + if (authorHex != null) { + val relays = + if (rawOutboxRelays) { + (outboxNote.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() ?: emptySet() } else { - note.author?.pubkeyHex - } - - if (authorHex != null) { - val relays = (outboxNote.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() ?: cache.relayHints.hintsForKey(authorHex).ifEmpty { null } ?: Constants.eventFinderRelays + } - relays.forEach { - if (!it.url.startsWith("wss://feeds.nostr.band") && - !it.url.startsWith("wss://filter.nostr.wine") && - !it.url.startsWith("wss://nwc.primal.net") && - !it.url.startsWith("wss://relay.getalby.com") - ) { - add(it, authorHex) - } + relays.forEach { + if (!it.url.startsWith("wss://feeds.nostr.band") && + !it.url.startsWith("wss://filter.nostr.wine") && + !it.url.startsWith("wss://nwc.primal.net") && + !it.url.startsWith("wss://relay.getalby.com") + ) { + add(it, authorHex) } } } } - - fun authorsPerRelaySnapshot( - authors: Set, - cache: LocalCache, - transformation: (Map>) -> T, - ): T { - val noteMetadata = - authors - .map { pubkeyHex -> - cache - .getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) - .flow() - .metadata.stateFlow.value - }.toTypedArray() - return transformation(authorsPerRelay(noteMetadata, cache)) } - fun toAuthorsPerRelayFlow( - authors: Set, - cache: LocalCache, - transformation: (Map>) -> T, - ): Flow { - val noteMetadataFlows = - authors.map { pubkeyHex -> - val note = cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) - note.flow().metadata.stateFlow - } + fun authorsPerRelaySnapshot( + authors: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): T { + val noteMetadata = + authors + .map { pubkeyHex -> + cache + .getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + .flow() + .metadata.stateFlow.value + }.toTypedArray() + return transformation(authorsPerRelay(noteMetadata, cache)) + } - return if (noteMetadataFlows.isEmpty()) { - MutableStateFlow(transformation(emptyMap())) - } else { - combine(noteMetadataFlows) { outboxRelays -> - transformation(authorsPerRelay(outboxRelays, cache)) - } + fun toAuthorsPerRelayFlow( + authors: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): Flow { + val noteMetadataFlows = + authors.map { pubkeyHex -> + val note = cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + note.flow().metadata.stateFlow + } + + return if (noteMetadataFlows.isEmpty()) { + MutableStateFlow(transformation(emptyMap())) + } else { + combine(noteMetadataFlows) { outboxRelays -> + transformation(authorsPerRelay(outboxRelays, cache)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt index b4f9bdbf6..e917a47fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt @@ -81,7 +81,7 @@ class AllFollowsByOutboxTopNavFilter( override fun toPerRelayFlow(cache: LocalCache): Flow { val authorsPerRelay = if (authors != null) { - OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + OutboxRelayLoader().toAuthorsPerRelayFlow(authors, cache) { it } } else { MutableStateFlow(emptyMap()) } @@ -112,7 +112,7 @@ class AllFollowsByOutboxTopNavFilter( override fun startValue(cache: LocalCache): AllFollowsTopNavPerRelayFilterSet { val authorsPerRelay = if (authors != null) { - OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + OutboxRelayLoader().authorsPerRelaySnapshot(authors, cache) { it } } else { emptyMap() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt index 63dc77fdb..96ae9d520 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt @@ -52,7 +52,7 @@ class AuthorsByOutboxTopNavFilter( ) override fun toPerRelayFlow(cache: LocalCache): Flow { - val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + val authorsPerRelay = OutboxRelayLoader().toAuthorsPerRelayFlow(authors, cache) { it } return combine(authorsPerRelay, blockedRelays) { authors, blocked -> convert(authors.minus(blocked)) @@ -60,7 +60,7 @@ class AuthorsByOutboxTopNavFilter( } override fun startValue(cache: LocalCache): AuthorsTopNavPerRelayFilterSet { - val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + val authorsPerRelay = OutboxRelayLoader().authorsPerRelaySnapshot(authors, cache) { it } return convert(authorsPerRelay.minus(blockedRelays.value)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt index 7b090f141..706b75fc9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt @@ -67,7 +67,7 @@ class SingleCommunityTopNavFilter( if (authors != null) { // go by authors - val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + val authorsPerRelay = OutboxRelayLoader().toAuthorsPerRelayFlow(authors, cache) { it } return combine(authorsPerRelay, blockedRelays) { authorsPerRelay, blocked -> SingleCommunityTopNavPerRelayFilterSet( @@ -100,7 +100,7 @@ class SingleCommunityTopNavFilter( if (authors != null) { // go by authors - val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + val authorsPerRelay = OutboxRelayLoader().authorsPerRelaySnapshot(authors, cache) { it } return SingleCommunityTopNavPerRelayFilterSet( authorsPerRelay.minus(blockedRelays.value).mapValues { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt index b2a5b68ea..76028ddbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt @@ -52,7 +52,7 @@ class MutedAuthorsByOutboxTopNavFilter( ) override fun toPerRelayFlow(cache: LocalCache): Flow { - val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + val authorsPerRelay = OutboxRelayLoader().toAuthorsPerRelayFlow(authors, cache) { it } return combine(authorsPerRelay, blockedRelays) { authors, blocked -> convert(authors.minus(blocked)) @@ -60,7 +60,7 @@ class MutedAuthorsByOutboxTopNavFilter( } override fun startValue(cache: LocalCache): MutedAuthorsTopNavPerRelayFilterSet { - val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + val authorsPerRelay = OutboxRelayLoader().authorsPerRelaySnapshot(authors, cache) { it } return convert(authorsPerRelay.minus(blockedRelays.value)) } From 8131f3db391a62afd712bd1300d5acaddad19059 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 Sep 2025 16:57:38 -0400 Subject: [PATCH 13/18] Update AGP --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b4dffbeb3..ffc728e65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanistAdaptive = "0.37.3" activityCompose = "1.10.1" -agp = "8.12.2" +agp = "8.13.0" android-compileSdk = "36" android-minSdk = "26" android-targetSdk = "36" From 8d73bf2cc4d47c409ec0f0303101c496452c1867 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 Sep 2025 17:44:40 -0400 Subject: [PATCH 14/18] Makes the new Video events non replaceable, while keeping the old ones in the replaceable --- .../amethyst/model/LocalCache.kt | 4 +- .../amethyst/ui/note/types/Video.kt | 17 +++-- .../amethyst/ui/note/types/VideoDisplay.kt | 7 +- .../client/single/basic/BasicRelayClient.kt | 3 + .../quartz/nip71Video/RegularVideoEvent.kt | 73 +++++++++++++++++++ .../nip71Video/ReplaceableVideoEvent.kt | 73 +++++++++++++++++++ .../quartz/nip71Video/VideoEvent.kt | 46 +++--------- .../quartz/nip71Video/VideoHorizontalEvent.kt | 3 +- .../quartz/nip71Video/VideoNormalEvent.kt | 12 +-- .../quartz/nip71Video/VideoShortEvent.kt | 9 +-- .../quartz/nip71Video/VideoVerticalEvent.kt | 2 +- 11 files changed, 186 insertions(+), 63 deletions(-) create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/RegularVideoEvent.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 5dad5907a..977b027bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1044,13 +1044,13 @@ object LocalCache : ILocalCache { event: VideoNormalEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) + ) = consumeRegularEvent(event, relay, wasVerified) private fun consume( event: VideoShortEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) + ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: StatusEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 0ae502b83..f49d1b10c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists @@ -75,18 +76,20 @@ fun VideoDisplay( accountViewModel: AccountViewModel, nav: INav, ) { - val event = (note.event as? VideoEvent) ?: return - val imeta = event.imetaTags().firstOrNull() ?: return + val videoEvent = (note.event as? VideoEvent) ?: return + val event = (videoEvent as? Event) ?: return - val title = event.title() - val summary = event.content.ifBlank { null }?.takeIf { title != it } + val imeta = videoEvent.imetaTags().firstOrNull() ?: return + + val title = videoEvent.title() + val summary = videoEvent.content.ifBlank { null }?.takeIf { title != it } val image = imeta.image.firstOrNull() val isYouTube = imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } val content by remember(note) { - val description = event.content.ifBlank { null } ?: event.alt() + val description = videoEvent.content.ifBlank { null } ?: event.alt() val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) val uri = note.toNostrUri() @@ -187,11 +190,11 @@ fun VideoDisplay( nav = nav, ) - if (event.hasHashtags()) { + if (videoEvent.hasHashtags()) { Row( Modifier.fillMaxWidth(), ) { - DisplayUncitedHashtags(event, summary, callbackUri, accountViewModel, nav) + DisplayUncitedHashtags(videoEvent, summary, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index c9cc553e8..ab73587d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip71Video.VideoEvent @@ -43,8 +44,10 @@ fun JustVideoDisplay( contentScale: ContentScale, accountViewModel: AccountViewModel, ) { - val event = (note.event as? VideoEvent) ?: return - val imeta = event.imetaTags().getOrNull(0) ?: return + val videoEvent = (note.event as? VideoEvent) ?: return + val event = (videoEvent as? Event) ?: return + + val imeta = videoEvent.imetaTags().getOrNull(0) ?: return val content by remember(note) { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 54c1f7e5e..d9318f5da 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -237,6 +237,9 @@ open class BasicRelayClient( text: String, onConnected: () -> Unit, ) { + if (text.contains("2e9244f75b36d47116b8c8bd5c4ea4d29fb1a3d64688d2524156af34c6124dc3")) { + Log.d(logTag, "AABBCC Receiving: $text") + } // Log.d(logTag, "Receiving: $text") stats.addBytesReceived(text.bytesUsedInMemory()) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/RegularVideoEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/RegularVideoEvent.kt new file mode 100644 index 000000000..0007b48c1 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/RegularVideoEvent.kt @@ -0,0 +1,73 @@ +/** + * 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.nip71Video + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +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 +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag + +@Immutable +abstract class RegularVideoEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, kind, tags, content, sig), + PublishedAtProvider, + VideoEvent, + RootScope { + @Transient var iMetas: List? = null + + override fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) + + override fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse) + + override fun textTrack() = tags.mapNotNull(ETag::parse) + + override fun segments() = tags.mapNotNull(SegmentTag::parse) + + override fun participants() = tags.mapNotNull(PTag::parse) + + override fun hashtags() = tags.hashtags() + + override fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) + + override fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) + + override fun imetaTags() = iMetas ?: imetas().map { VideoMeta.parse(it) }.also { iMetas = it } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt new file mode 100644 index 000000000..f57e4cc43 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt @@ -0,0 +1,73 @@ +/** + * 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.nip71Video + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +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 +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag + +@Immutable +abstract class ReplaceableVideoEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, kind, tags, content, sig), + PublishedAtProvider, + VideoEvent, + RootScope { + @Transient var iMetas: List? = null + + override fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) + + override fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse) + + override fun textTrack() = tags.mapNotNull(ETag::parse) + + override fun segments() = tags.mapNotNull(SegmentTag::parse) + + override fun participants() = tags.mapNotNull(PTag::parse) + + override fun hashtags() = tags.hashtags() + + override fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) + + override fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) + + override fun imetaTags() = iMetas ?: imetas().map { VideoMeta.parse(it) }.also { iMetas = it } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt index 71ec35c8c..d4d773c04 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt @@ -21,52 +21,30 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.IEvent 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 -import com.vitorpamplona.quartz.nip71Video.tags.DurationTag import com.vitorpamplona.quartz.nip71Video.tags.SegmentTag -import com.vitorpamplona.quartz.nip92IMeta.imetas -import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag -import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag @Immutable -abstract class VideoEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), - PublishedAtProvider, - RootScope { - @Transient var iMetas: List? = null +interface VideoEvent : IEvent { + fun title(): String? - fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + fun publishedAt(): Long? - override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) + fun duration(): Int? - fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse) + fun textTrack(): List - fun textTrack() = tags.mapNotNull(ETag::parse) + fun segments(): List - fun segments() = tags.mapNotNull(SegmentTag::parse) + fun participants(): List - fun participants() = tags.mapNotNull(PTag::parse) + fun hashtags(): List - fun hashtags() = tags.hashtags() + fun mimeType(): String? - private fun mimeType() = tags.firstNotNullOfOrNull(MimeTypeTag::parse) + fun hash(): String? - private fun hash() = tags.firstNotNullOfOrNull(HashSha256Tag::parse) - - fun imetaTags() = iMetas ?: imetas().map { VideoMeta.parse(it) }.also { iMetas = it } + fun imetaTags(): List } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt index 308b1b6e4..39738bb91 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip71Video.videoIMetas import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID @@ -38,7 +39,7 @@ class VideoHorizontalEvent( tags: Array>, content: String, sig: HexKey, -) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34235 diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoNormalEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoNormalEvent.kt index bdb3fabb6..73fbd8b01 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoNormalEvent.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoNormalEvent.kt @@ -24,11 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils -import java.util.UUID @Immutable class VideoNormalEvent( @@ -38,7 +36,7 @@ class VideoNormalEvent( tags: Array>, content: String, sig: HexKey, -) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : RegularVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 21 @@ -47,10 +45,9 @@ class VideoNormalEvent( fun build( video: VideoMeta, description: String, - dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(description, dTag, createdAt) { + ) = build(description, createdAt) { videoIMeta(video) initializer() } @@ -58,21 +55,18 @@ class VideoNormalEvent( fun build( video: List, description: String, - dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(description, dTag, createdAt) { + ) = build(description, createdAt) { videoIMetas(video) initializer() } fun build( description: String, - dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { - dTag(dTag) alt(ALT_DESCRIPTION) initializer() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoShortEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoShortEvent.kt index 4be29bde8..64fe9de6b 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoShortEvent.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoShortEvent.kt @@ -24,11 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils -import java.util.UUID @Immutable class VideoShortEvent( @@ -38,7 +36,7 @@ class VideoShortEvent( tags: Array>, content: String, sig: HexKey, -) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : RegularVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 22 @@ -47,21 +45,18 @@ class VideoShortEvent( fun build( video: VideoMeta, description: String, - dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(description, dTag, createdAt) { + ) = build(description, createdAt) { videoIMeta(video) initializer() } fun build( description: String, - dTag: String = UUID.randomUUID().toString(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { - dTag(dTag) alt(ALT_DESCRIPTION) initializer() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt index b3ae883fe..0751775fe 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt @@ -38,7 +38,7 @@ class VideoVerticalEvent( tags: Array>, content: String, sig: HexKey, -) : VideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34236 From 7de119dd4fb84cecb8008aa08bceffeb8cef82f3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 Sep 2025 17:48:24 -0400 Subject: [PATCH 15/18] removes logs --- .../nip01Core/relay/client/single/basic/BasicRelayClient.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index d9318f5da..54c1f7e5e 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -237,9 +237,6 @@ open class BasicRelayClient( text: String, onConnected: () -> Unit, ) { - if (text.contains("2e9244f75b36d47116b8c8bd5c4ea4d29fb1a3d64688d2524156af34c6124dc3")) { - Log.d(logTag, "AABBCC Receiving: $text") - } // Log.d(logTag, "Receiving: $text") stats.addBytesReceived(text.bytesUsedInMemory()) From 19c24af59288e620df3a71c4923098159d376a3a Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 3 Sep 2025 22:05:06 +0000 Subject: [PATCH 16/18] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 0fe7fdeb6..c38a05009 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -148,6 +148,7 @@ Nie udało się zapisać filmu Dodaj zdjęcie Zrób zdjęcie + Nagraj film Nagraj wiadomość Nagrywanie wiadomości Kliknij i przytrzymaj aby nagrać wiadomość @@ -163,6 +164,7 @@ Zablokowani użytkownicy Nowe Wpisy Komentarze + Aktualności Moderacja Wpisy Odpowiedzi @@ -887,10 +889,13 @@ Wgrywanie DM Ustawienia Transmiterów Publiczne transmitery domowe + Użytkownik publikuje swoje wpisy na tych transmiterach Ten typ transmitera przechowuje całą zawartość. Amethyst będzie wysyłać Twoje posty tutaj, a inni będą korzystać z tych transmiterów, aby znaleźć Twoje treści. Wstaw od 1 do 3 transmiterów. Mogą to być transmitery prywatne, płatne lub publiczne. Publiczne transmitery odbiorcze + Użytkownik otrzymuje powiadomienia na tych transmiterach Ten typ transmitera odbiera wszystkie odpowiedzi, komentarze, polubienia i zapy do Twoich postów. Mogą to być płatne lub bezpłatne transmitery. Limity ustawione przez operatora transmitera mogą ograniczać otrzymywane powiadomienia, zarówno te dobre, jak i złe. Na przykład, jeśli jesteś atakowany przez spam w komentarzach, płatne transmitery mogą odfiltrować spam. Wstaw od 1 do 3 transmiterów. Odbiorcze transmitery DM + Użytkownik otrzymuje DM na tych transmiterach Wstaw od 1 do 3 transmiterów, które będą służyć jako Twoja prywatna skrzynka odbiorcza. Inni będą używać tych transmiterów do wysyłania wiadomości DM do Ciebie. Transmitery odbiorcze DM powinny akceptować dowolne wiadomości od każdego, ale pozwalać tylko na ich pobieranie. Dobre opcje to:\n - inbox.nostr.wine (płatny)\n - you.nostr1.com (transmitery osobiste - płatny) Transmitery Prywatne Wstaw od 1 do 3 transmiterów przechowujących dane zdarzeń, których nikt inny nie widzi, takich jak Wersje robocze i/lub ustawienia aplikacji. Idealnie byłoby, gdyby te transmitery były lokalne lub wymagały uwierzytelnienia przed pobraniem treści każdego użytkownika. @@ -1005,7 +1010,7 @@ Pobierz Nie udało się otworzyć pliku Brak zainstalowanych aplikacji torrent do otwarcia i pobrania pliku. - Wybierz listę, aby filtrować kanał + Wybierz listę, aby filtrować aktualności Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna Publiczna wiadomość @@ -1019,6 +1024,8 @@ Odtwórz Otwórz rozwijane menu Opcja %1$s dla %2$s + Wybrano filtr aktualności %1$s + Filtr aktualności, %1$s Znaleziono raport o błędzie Czy chcesz wysłać ostatni raport o awarii do Amethyst w DM? Żadne dane osobowe nie będą udostępnione Prześlij From 103ef01be4e08cbb15f3839e3d5da24e61c43dc5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 Sep 2025 11:10:54 -0400 Subject: [PATCH 17/18] Adds support for NIP-53 Meeting room to quartz --- .../com/vitorpamplona/quartz/EventFactory.kt | 6 + .../meetingSpaces/MeetingRoomEvent.kt | 161 ++++++++++++++++++ .../meetingSpaces/MeetingSpaceEvent.kt | 97 +++++++++++ .../meetingSpaces/tags/EndpointUrlTag.kt | 41 +++++ .../meetingSpaces/tags/MeetingSpaceTag.kt | 150 ++++++++++++++++ .../meetingSpaces/tags/RecordingTag.kt | 41 +++++ .../meetingSpaces/tags/RelayListTag.kt | 50 ++++++ .../meetingSpaces/tags/RoomNameTag.kt | 41 +++++ .../meetingSpaces/tags/ServiceUrlTag.kt | 41 +++++ .../meetingSpaces/tags/StatusTag.kt | 70 ++++++++ .../presence/MeetingRoomPresenceEvent.kt | 91 ++++++++++ .../presence/TagArrayBuilderExt.kt | 33 ++++ .../presence/tags/HandRaisedTag.kt | 41 +++++ .../presence/tags/MeetingRoomTag.kt | 150 ++++++++++++++++ .../streaming/LiveActivitiesEvent.kt | 34 ++-- .../streaming/tags/ParticipantTag.kt | 1 + .../streaming/tags/PinnedEventTag.kt | 51 ++++++ .../streaming/tags/RecordingTag.kt | 41 +++++ 18 files changed, 1130 insertions(+), 10 deletions(-) create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/EndpointUrlTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RecordingTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RelayListTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ServiceUrlTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/HandRaisedTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/MeetingRoomTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/PinnedEventTag.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RecordingTag.kt diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/EventFactory.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/EventFactory.kt index 5bf239abd..54e6f80a1 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/EventFactory.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/EventFactory.kt @@ -93,6 +93,9 @@ import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -253,6 +256,9 @@ class EventFactory { LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) + MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig) + MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig) + MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt new file mode 100644 index 000000000..07fd1e26a --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingRoomEvent.kt @@ -0,0 +1,161 @@ +/** + * 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.nip53LiveActivities.meetingSpaces + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.PinnedEventTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RecordingTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RelayListTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StartsTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class MeetingRoomEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints(): List { + val pinnedEvents = pinned() + if (pinnedEvents.isEmpty()) return emptyList() + + val relays = allRelayUrls() + + return if (relays.isNotEmpty()) { + pinnedEvents + .map { eventId -> + relays.map { relay -> + EventIdHint(eventId, relay) + } + }.flatten() + } else { + emptyList() + } + } + + override fun linkedEventIds() = tags.mapNotNull(PinnedEventTag::parse) + + override fun addressHints(): List = tags.mapNotNull(MeetingSpaceTag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(MeetingSpaceTag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(ParticipantTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(ParticipantTag::parseKey) + + fun interactiveRoom() = tags.firstNotNullOfOrNull(MeetingSpaceTag::parse) + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun streaming() = tags.firstNotNullOfOrNull(StreamingTag::parse) + + fun recording() = tags.firstNotNullOfOrNull(RecordingTag::parse) + + fun starts() = tags.firstNotNullOfOrNull(StartsTag::parse) + + fun ends() = tags.firstNotNullOfOrNull(EndsTag::parse) + + fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum)) + + fun isLive() = status() == StatusTag.STATUS.LIVE + + fun currentParticipants() = tags.firstNotNullOfOrNull(CurrentParticipantsTag::parse) + + fun totalParticipants() = tags.firstNotNullOfOrNull(TotalParticipantsTag::parse) + + fun participantKeys(): List = tags.mapNotNull(ParticipantTag::parseKey) + + fun participants() = tags.mapNotNull(ParticipantTag::parse) + + fun relays() = tags.mapNotNull(RelayListTag::parse).flatten() + + fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).flatten() + + fun hasHost() = tags.any(ParticipantTag::isHost) + + fun host() = tags.firstNotNullOfOrNull(ParticipantTag::parseHost) + + fun hosts() = tags.mapNotNull(ParticipantTag::parseHost) + + fun pinned() = tags.mapNotNull(PinnedEventTag::parse) + + fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? = + if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) { + StatusTag.STATUS.ENDED + } else if (eventStatus == StatusTag.STATUS.PLANNED) { + val starts = starts() + val ends = ends() + if (starts != null && starts < TimeUtils.oneHourAgo()) { + StatusTag.STATUS.ENDED + } else if (ends != null && ends < TimeUtils.oneHourAgo()) { + StatusTag.STATUS.ENDED + } else { + eventStatus + } + } else { + eventStatus + } + + fun participantsIntersect(keySet: Set): Boolean = keySet.contains(pubKey) || tags.any(ParticipantTag::isIn, keySet) + + companion object { + const val KIND = 30313 + const val ALT = "Meeting room event" + + suspend fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MeetingRoomEvent { + val tags = arrayOf(AltTag.assemble(ALT)) + return signer.sign(createdAt, KIND, tags, "") + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt new file mode 100644 index 000000000..8195931b0 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag +import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.EndpointUrlTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RelayListTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RoomNameTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.ServiceUrlTag +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class MeetingSpaceEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(ParticipantTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(ParticipantTag::parseKey) + + fun room() = tags.firstNotNullOfOrNull(RoomNameTag::parse) + + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun status() = checkStatus(tags.firstNotNullOfOrNull(StatusTag::parseEnum)) + + fun isLive() = status() != StatusTag.STATUS.CLOSED + + fun service() = tags.firstNotNullOfOrNull(ServiceUrlTag::parse) + + fun endpoint() = tags.firstNotNullOfOrNull(EndpointUrlTag::parse) + + fun relays() = tags.mapNotNull(RelayListTag::parse).flatten() + + fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).flatten() + + fun participantKeys(): List = tags.mapNotNull(ParticipantTag::parseKey) + + fun participants() = tags.mapNotNull(ParticipantTag::parse) + + fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? = + if (eventStatus != StatusTag.STATUS.CLOSED && createdAt < TimeUtils.eightHoursAgo()) { + StatusTag.STATUS.CLOSED + } else { + eventStatus + } + + fun participantsIntersect(keySet: Set): Boolean = keySet.contains(pubKey) || tags.any(ParticipantTag::isIn, keySet) + + companion object Companion { + const val KIND = 30312 + const val ALT = "Interactive room event" + + suspend fun create( + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): MeetingSpaceEvent { + val tags = arrayOf(AltTag.assemble(ALT)) + return signer.sign(createdAt, KIND, tags, "") + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/EndpointUrlTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/EndpointUrlTag.kt new file mode 100644 index 000000000..56961287a --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/EndpointUrlTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class EndpointUrlTag { + companion object Companion { + const val TAG_NAME = "endpoint" + + @JvmStatic + fun parse(tag: Array): 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(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt new file mode 100644 index 000000000..e7237ca0c --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt @@ -0,0 +1,150 @@ +/** + * 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.nip53LiveActivities.meetingSpaces.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class MeetingSpaceTag( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) { + fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + (relayHint?.url?.bytesUsedInMemory() ?: 0) + + fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) + + fun toTagArray() = assemble(address, relayHint) + + fun toTagIdOnly() = assemble(address, null) + + companion object Companion { + const val TAG_NAME = "a" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + @JvmStatic + fun isTagged( + tag: Array, + address: MeetingSpaceTag, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + @JvmStatic + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + @JvmStatic + fun isTaggedWithKind( + tag: Array, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind) + + @JvmStatic + fun parse( + aTagId: String, + relay: String?, + ) = Address.parse(aTagId)?.let { + MeetingSpaceTag(it, relay?.let { RelayUrlNormalizer.normalizeOrNull(it) }) + } + + @JvmStatic + fun parse(tag: Array): MeetingSpaceTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return parse(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1])?.toValue() + } + + @JvmStatic + fun parseAddress(tag: Array): Address? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1]) + } + + @JvmStatic + fun parseAddressId(tag: Array): 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 parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + @JvmStatic + fun assemble( + aTagId: HexKey, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + @JvmStatic + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) + + @JvmStatic + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RecordingTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RecordingTag.kt new file mode 100644 index 000000000..df2d007df --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RecordingTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class RecordingTag { + companion object { + const val TAG_NAME = "recording" + + @JvmStatic + fun parse(tag: Array): 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(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RelayListTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RelayListTag.kt new file mode 100644 index 000000000..c1e82b1cc --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RelayListTag.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayListTag { + companion object { + const val TAG_NAME = "relays" + + @JvmStatic + fun parse(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val relays = + tag.mapIndexedNotNull { index, s -> + if (index == 0) null else RelayUrlNormalizer.normalizeOrNull(s) + } + + if (relays.isEmpty()) return null + + return relays + } + + @JvmStatic + fun assemble(urls: List) = arrayOf(TAG_NAME) + urls.map { it.url }.toTypedArray() + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt new file mode 100644 index 000000000..b0cd4dfd3 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class RoomNameTag { + companion object Companion { + const val TAG_NAME = "room" + + @JvmStatic + fun parse(tag: Array): 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(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ServiceUrlTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ServiceUrlTag.kt new file mode 100644 index 000000000..063048451 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/ServiceUrlTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ServiceUrlTag { + companion object Companion { + const val TAG_NAME = "service" + + @JvmStatic + fun parse(tag: Array): 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(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt new file mode 100644 index 000000000..99440c145 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/StatusTag.kt @@ -0,0 +1,70 @@ +/** + * 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.nip53LiveActivities.meetingSpaces.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class StatusTag { + enum class STATUS( + val code: String, + ) { + OPEN("open"), + PRIVATE("private"), + CLOSED("closed"), + ; + + fun toTagArray() = assemble(this) + + companion object { + fun parse(code: String): STATUS? = + when (code) { + STATUS.OPEN.code -> STATUS.OPEN + STATUS.PRIVATE.code -> STATUS.PRIVATE + STATUS.CLOSED.code -> STATUS.CLOSED + else -> null + } + } + } + + companion object { + const val TAG_NAME = "status" + + @JvmStatic + fun parse(tag: Array): 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 parseEnum(tag: Array): STATUS? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return STATUS.parse(tag[1]) + } + + @JvmStatic + fun assemble(status: STATUS) = arrayOf(TAG_NAME, status.code) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt new file mode 100644 index 000000000..4919302c7 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt @@ -0,0 +1,91 @@ +/** + * 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.nip53LiveActivities.presence + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class MeetingRoomPresenceEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints(): List = tags.mapNotNull(MeetingSpaceTag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(MeetingSpaceTag::parseAddressId) + + fun interactiveRoom() = tags.firstNotNullOfOrNull(MeetingSpaceTag::parse) + + fun handRaised() = tags.firstNotNullOfOrNull(HandRaisedTag::parse) + + companion object Companion { + const val KIND = 10312 + const val ALT = "Room Presence tag" + + fun createAddress( + pubKey: HexKey, + dtag: String, + ): Address = Address(KIND, pubKey, dtag) + + fun createAddressATag( + pubKey: HexKey, + dtag: String, + ): ATag = ATag(KIND, pubKey, dtag, null) + + fun createAddressTag( + pubKey: HexKey, + dtag: String, + ): String = Address.assemble(KIND, pubKey, dtag) + + fun build( + root: MeetingRoomEvent, + handRaised: Boolean?, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(root.title() ?: ALT) + + roomMeeting(MeetingSpaceTag(root.address(), root.relays().firstOrNull())) + + handRaised?.let { + handRaised(it) + } + initializer() + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt new file mode 100644 index 000000000..138e7ea5f --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt @@ -0,0 +1,33 @@ +/** + * 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.nip53LiveActivities.presence + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag + +fun TagArrayBuilder.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toTagArray()) + +fun TagArrayBuilder.roomMeeting(rep: EventHintBundle) = addUnique(rep.toATag().toATagArray()) + +fun TagArrayBuilder.handRaised(raised: Boolean) = addUnique(HandRaisedTag.assemble(raised)) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/HandRaisedTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/HandRaisedTag.kt new file mode 100644 index 000000000..bf3646c0a --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/HandRaisedTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.presence.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class HandRaisedTag { + companion object Companion { + const val TAG_NAME = "hand" + + @JvmStatic + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] == "1" + } + + @JvmStatic + fun assemble(handRaised: Boolean) = arrayOf(TAG_NAME, if (handRaised) "1" else "0") + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/MeetingRoomTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/MeetingRoomTag.kt new file mode 100644 index 000000000..c59d1d714 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/tags/MeetingRoomTag.kt @@ -0,0 +1,150 @@ +/** + * 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.nip53LiveActivities.presence.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.bytesUsedInMemory +import com.vitorpamplona.quartz.utils.ensure +import com.vitorpamplona.quartz.utils.pointerSizeInBytes + +class MeetingRoomTag( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) { + fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + (relayHint?.url?.bytesUsedInMemory() ?: 0) + + fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) + + fun toTagArray() = assemble(address, relayHint) + + fun toTagIdOnly() = assemble(address, null) + + companion object Companion { + const val TAG_NAME = "a" + + @JvmStatic + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + @JvmStatic + fun isTagged( + tag: Array, + address: MeetingRoomTag, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + @JvmStatic + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + @JvmStatic + fun isTaggedWithKind( + tag: Array, + kind: String, + ) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], kind) + + @JvmStatic + fun parse( + aTagId: String, + relay: String?, + ) = Address.parse(aTagId)?.let { + MeetingRoomTag(it, relay?.let { RelayUrlNormalizer.normalizeOrNull(it) }) + } + + @JvmStatic + fun parse(tag: Array): MeetingRoomTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return parse(tag[1], tag.getOrNull(2)) + } + + @JvmStatic + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1])?.toValue() + } + + @JvmStatic + fun parseAddress(tag: Array): Address? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return Address.parse(tag[1]) + } + + @JvmStatic + fun parseAddressId(tag: Array): 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 parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + @JvmStatic + fun assemble( + aTagId: HexKey, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + @JvmStatic + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) + + @JvmStatic + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index b11a39483..4e4217609 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -24,13 +24,10 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.any -import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag @@ -38,6 +35,8 @@ import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.PinnedEventTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RecordingTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RelayListTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StartsTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag @@ -55,15 +54,26 @@ class LiveActivitiesEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider, PubKeyHintProvider { - override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + override fun eventHints(): List { + val pinnedEvents = pinned() + if (pinnedEvents.isEmpty()) return emptyList() - override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId) + val relays = allRelayUrls() - override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + return if (relays.isNotEmpty()) { + pinnedEvents + .map { eventId -> + relays.map { relay -> + EventIdHint(eventId, relay) + } + }.flatten() + } else { + emptyList() + } + } - override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(QTag::parseAddressId) + override fun linkedEventIds() = tags.mapNotNull(PinnedEventTag::parse) override fun pubKeyHints() = tags.mapNotNull(ParticipantTag::parseAsHint) @@ -77,6 +87,8 @@ class LiveActivitiesEvent( fun streaming() = tags.firstNotNullOfOrNull(StreamingTag::parse) + fun recording() = tags.firstNotNullOfOrNull(RecordingTag::parse) + fun starts() = tags.firstNotNullOfOrNull(StartsTag::parse) fun ends() = tags.firstNotNullOfOrNull(EndsTag::parse) @@ -93,7 +105,7 @@ class LiveActivitiesEvent( fun participants() = tags.mapNotNull(ParticipantTag::parse) - fun relays() = tags.mapNotNull(RelayListTag::parse) + fun relays() = tags.mapNotNull(RelayListTag::parse).flatten() fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).flatten() @@ -103,6 +115,8 @@ class LiveActivitiesEvent( fun hosts() = tags.mapNotNull(ParticipantTag::parseHost) + fun pinned() = tags.mapNotNull(PinnedEventTag::parse) + fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? = if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) { StatusTag.STATUS.ENDED diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt index 5478fedf1..c2f929f66 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt @@ -35,6 +35,7 @@ enum class ROLE( val code: String, ) { HOST("host"), + MODERATOR("moderator"), SPEAKER("speaker"), } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/PinnedEventTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/PinnedEventTag.kt new file mode 100644 index 000000000..da9810bb2 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/PinnedEventTag.kt @@ -0,0 +1,51 @@ +/** + * 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.nip53LiveActivities.streaming.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.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +class PinnedEventTag { + companion object { + const val TAG_NAME = "pinned" + + fun isIn( + tag: Array, + eventIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds + + @JvmStatic + fun parse(tag: Tag): 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 assemble(eventId: HexKey) = arrayOfNotNull(TAG_NAME, eventId) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RecordingTag.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RecordingTag.kt new file mode 100644 index 000000000..7f4c6953b --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RecordingTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class RecordingTag { + companion object { + const val TAG_NAME = "recording" + + @JvmStatic + fun parse(tag: Array): 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(url: String) = arrayOf(TAG_NAME, url) + } +} From 88457eb101cdaab28e3941d6cacd8cfe22f539e2 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Thu, 4 Sep 2025 15:13:49 +0000 Subject: [PATCH 18/18] New Crowdin translations by GitHub Action --- .../src/main/res/values-hi-rIN/strings.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 19efe1c36..6dec4915c 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -148,6 +148,7 @@ चलचित्र को सुरक्षित रखने में असफल चित्र आरोहण एक चित्र लें + चलचित्र का अभिलेखन करें ऐक संदेश का अभिलेखन करें ऐक संदेश का अभिलेखन करें टाँकें तथा दबाए रखें संदेश का अभिलेखन करने के लिए @@ -163,6 +164,8 @@ बाधित उपयोगकर्ता नये सूत्र सम्वाद + सूचनावली + नियमन पंक्ति टीकाएँ प्रतिवचन आपका @@ -655,6 +658,8 @@ सदस्यों के लिए व्याख्यान नाम का परिवर्तन नये लक्ष्य के लिए। टाँकाफलक से चिपकाएँ + अमान्य निप॰-४७ वैश्विकसाधनपरिचायक + यह %1$s एक मान्य निप॰-४७ प्रवेशांकन वैश्विकसाधनपरिचायक नहीं है। क्रमक के प्रयोगमाध्यम के लिए अन्धकारमय, प्रकाशवान अथवा यन्त्रव्यवस्थित प्रदर्शनशैली स्वचालित रूप से चित्र तथा जिफ॰ का अवरोहण करें @@ -672,6 +677,9 @@ अभिलेख जोडा गया आपके परिचय चित्रालय में तब बनाया गया नियमावली + हमारा परिचय + दिशा निर्देश + नियामक आम्बेर के साथ प्रवेशांकन करें आपकी स्थिति का नवीकरण करें अपक्रम संदेश परखने में अपक्रम @@ -884,10 +892,13 @@ सीधा संदेश आरोहण पुनःप्रसारक स्थापना विकल्प सार्वजनिक मुख्य पुनःप्रसारक + प्रयोक्ता अपने विषयवस्तुओं को इन पुनःप्रसारकों को भेज रहा है यह पुनःप्रसारक प्रकार आपके सभी विषयवस्तु रखता है। अमेथिस्ट आपके पत्रों को प्रकाशित करने के लिए यहाँ भेजेगा तथा अन्य लोग आपके विषयवस्तु ढूँढने के लिए इनका प्रयोग करेंगे। १ - ३ पुनःप्रसारकों को जोडें। ये व्यक्तिगत अथवा सशुल्क अथवा सार्वजनिक पुनःप्रसारक हो सकते हैं। सार्वजनिक आगतपेटिका पुनःप्रसारक + प्रयोक्ता इन पुनःप्रसारकों पर सूचनाएँ प्राप्त कर रहा है यह पुनःप्रसारक प्रकार आपके प्रकाशित पत्रों को भेजे गये सभी प्रतिवचन, टिप्पणियाँ, इष्टसूचक तथा ज्सापों को प्राप्त करता है। १ - ३ पुनःप्रसारकों को जोडें तथा सुनिश्चित करें कि वे किसी से भी पत्र स्वीकारते हैं। सीधा संदेश आगतपेटिका पुनःप्रसारक + प्रयोक्ता इन पुनःप्रसारकों पर सीधा सन्देश प्राप्त कर रहा है आपके निजी आगतपेटिका के रूप में १ - ३ पुनःप्रसारकों को जोडें। अन्य लोग इनका उपयोग करेंगे आपको सी॰सं॰ भेजने के लिए। सी॰सं॰ आगतपेटिका पुनःप्रसारकों को किसी से भी सन्देश स्वीकारना चाहिए पर उनका अवरोहण अनुमति केवल आपको देना चाहिए। ये अच्छे विकल्प हैं :\n - inbox.nostr.wine (सशुल्क)\n - auth.nostr1.com (शुल्करहित)\n - you.nostr1.com (व्यक्तिगत पुनःप्रसारक - सशुल्क) निजी पुनःप्रसारक १ - ३ पुनःप्रसारकों को जोडें उन घटनाओं को रखने के लिए जो कोई अन्य नहीं देख सकता जैसे कि पाण्डुलिपियाँ अथवा क्रमक के स्थापना विकल्प। आदर्शतः ये पुनःप्रसारक स्थानीय होने चाहिए अथवा प्रत्येक उपयोगकर्ता के विषयवस्तु अवरोहण के पूर्व परिचय प्रमाणीकरण अनिवार्य होना चाहिए। @@ -1014,4 +1025,12 @@ यहाँ प्रस्तुत भाषाओं का अनुवाद नहीं होगा। भाषा चयन करें हटाने के लिए जिससे उसका अनुवाद पुनः होने लगेगा। विराम चलाएँ + विकल्प सूची खोलें + %2$s में से %1$s विकल्प + सूचनावली छलनी। %1$s चयनित + सूचनावली छलनी। %1$s + क्रमदोष सूचनापत्र प्राप्त + क्या आप निकटकालिक क्रमदोष सूचनापत्र एक सीधे सन्देश में अमेथिस्ट को भेजना चाहते हैं। कोई व्यक्तिगत जानकारी बाँटी नहीं जाएगी + भेजें + यह सन्देश %1$d दिनों में अदृश्य हो जाएगा