feat: back the podcast authoring hub with a dedicated REQ

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
Claude
2026-06-28 21:17:02 +00:00
parent 97df84ee65
commit 6db98c2a73
6 changed files with 220 additions and 1 deletions
@@ -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,
@@ -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) {
@@ -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<RelayBasedFilter> {
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,
)
}
}
@@ -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<MyPodcastQueryState>,
) : PerUserEoseManager<MyPodcastQueryState>(client, allKeys) {
override fun updateFilter(
key: MyPodcastQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterMyPodcast(user(key), since)
override fun user(key: MyPodcastQueryState) = key.user
}
@@ -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<MyPodcastQueryState>() {
val group =
listOf(
MyPodcastFeedSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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)
}