From 6db98c2a73ebe284dff85f1d556be000523bd7a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:17:02 +0000 Subject: [PATCH] feat: back the podcast authoring hub with a dedicated REQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Your podcast" hub previously listed only what happened to be in LocalCache, so on a fresh install (or before the feed had loaded the creator's events) it could show an empty or stale catalog. Add a MyPodcast subscription (mirrors the OnePodcast assembler trio) that, while the hub is on screen, keeps a REQ open for the creator's OWN Podcasting-2.0 catalog on their outbox relays: the addressable episodes (30054) and trailers (30055) by author, plus the show-metadata kind:30078 constrained to #d=["podcast-metadata"] (reusing the existing constant so the overloaded app-data kind isn't pulled wholesale). Registered in RelaySubscriptionsCoordinator. The hub now also reacts to LocalCache.live.newEventBundles — when the creator's own episodes/trailers/metadata arrive over the REQ, the lists refresh in place rather than only on resume. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../RelaySubscriptionsCoordinator.kt | 3 + .../authoring/PodcastAuthoringScreen.kt | 24 ++++++- .../podcasts/datasource/FilterMyPodcast.kt | 69 +++++++++++++++++++ .../datasource/MyPodcastFeedSubAssembler.kt | 38 ++++++++++ .../datasource/MyPodcastFilterAssembler.kt | 46 +++++++++++++ .../MyPodcastFilterAssemblerSubscription.kt | 41 +++++++++++ 6 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9cbfccfa28..e6eff8162c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLi import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.MyPodcastFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler @@ -144,6 +145,7 @@ class RelaySubscriptionsCoordinator( val podcastEpisodes = PodcastEpisodesFilterAssembler(client) val podcasts = PodcastsFilterAssembler(client) val onePodcast = OnePodcastFilterAssembler(client) + val myPodcast = MyPodcastFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val napplets = NappletsFilterAssembler(client) val nsites = NsitesFilterAssembler(client) @@ -196,6 +198,7 @@ class RelaySubscriptionsCoordinator( podcastEpisodes, podcasts, onePodcast, + myPodcast, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt index ac3e4bcf34..ee6ee19595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -63,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.MyPodcastFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.nip01Core.core.Address @@ -85,12 +87,32 @@ fun PodcastAuthoringScreen( ) { val me = accountViewModel.account.userProfile().pubkeyHex - // Bump on each resume so returning from a composer re-scans LocalCache. + // Keep a REQ open for the creator's own catalog while the hub is visible, so the lists below + // fill in from relays even when nothing was cached yet. + MyPodcastFilterAssemblerSubscription(accountViewModel) + + // Re-scan LocalCache on each resume (returning from a composer) and whenever the creator's own + // podcast events arrive over the open REQ, so the lists stay current without a manual refresh. var refresh by remember { mutableIntStateOf(0) } LifecycleResumeEffect(Unit) { refresh++ onPauseOrDispose { } } + LaunchedEffect(me) { + LocalCache.live.newEventBundles.collect { bundle -> + val mine = + bundle.any { + val e = it.event + e?.pubKey == me && + ( + e is Podcasting20EpisodeEvent || + e is Podcasting20TrailerEvent || + (e is AppSpecificDataEvent && e.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) + ) + } + if (mine) refresh++ + } + } val show = remember(refresh) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt new file mode 100644 index 0000000000..28c246e084 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt @@ -0,0 +1,69 @@ +/* + * 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.amethyst.ui.screen.loggedIn.podcasts.datasource + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_METADATA_D_FILTER +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent + +// What the authoring hub needs about the logged-in creator's OWN Podcasting-2.0 catalog: +// the addressable episodes (kind 30054) and trailers (kind 30055)... +private val MyPodcastFeedKinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND) + +/** + * REQ for the logged-in creator's own Podcasting-2.0 catalog, so the authoring hub reliably shows + * their show, episodes and trailers even on a fresh install (rather than only what happens to be in + * [LocalCache]). Two filters: the addressable episodes/trailers by author, and the show-metadata + * `kind:30078` constrained to `#d=["podcast-metadata"]` (that kind is overloaded, so the constraint + * keeps the REQ from pulling every app's NIP-78 data). Queried on the creator's own outbox relays. + */ +fun filterMyPodcast( + user: User, + since: SincePerRelayMap?, +): List { + val relays = + user.outboxRelays()?.ifEmpty { null } + ?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex)) + + val authors = setOf(user.pubkeyHex) + + return relays.flatMap { relay -> + filterPodcastEventsByAuthors( + relay = relay, + kinds = MyPodcastFeedKinds, + authors = authors, + since = since?.get(relay)?.time, + ) + + filterPodcastEventsByAuthors( + relay = relay, + kinds = PODCASTING20_METADATA_KINDS, + authors = authors, + since = since?.get(relay)?.time, + additionalTags = PODCAST_METADATA_D_FILTER, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt new file mode 100644 index 0000000000..d9ce4aba05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt @@ -0,0 +1,38 @@ +/* + * 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.amethyst.ui.screen.loggedIn.podcasts.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class MyPodcastFeedSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: MyPodcastQueryState, + since: SincePerRelayMap?, + ): List = filterMyPodcast(user(key), since) + + override fun user(key: MyPodcastQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt new file mode 100644 index 0000000000..443d114108 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +// Keyed by the logged-in creator's own pubkey. Drives the authoring hub's REQ for the creator's +// own Podcasting-2.0 catalog (episodes, trailers, and the show-metadata event). +class MyPodcastQueryState( + val user: User, +) + +class MyPodcastFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + MyPodcastFeedSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..9fd9240b5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.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.amethyst.ui.screen.loggedIn.podcasts.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * While the authoring hub is on screen, keeps a REQ open for the logged-in creator's own + * Podcasting-2.0 catalog (episodes, trailers, and show metadata), so the hub fills in even when the + * events weren't already cached. + */ +@Composable +fun MyPodcastFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + val state = + remember(accountViewModel) { + MyPodcastQueryState(accountViewModel.account.userProfile()) + } + + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().myPodcast) +}