From a98ec62004026efa722c7aa48beabb444499e9ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 18:15:24 +0000 Subject: [PATCH] feat(podcasts): render nostr-native podcast:person credits as real profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Podcasting-2.0 person's href points at an npub/nprofile (bare, nostr: URI, or an njump-style link), upgrade the free-text credit to a real Nostr profile: the standard ClickableUserPicture + UsernameDisplay, tappable through to the profile. Plain web links keep the free-text card with the default profile-image loader. - quartz: PodcastPerson.nostrPubKey() resolves href → pubkey via Nip19Parser (npub/nprofile only). Covered by PodcastPersonSoundbiteTest. - UI: PodcastPeople branches per person — LoadUser + standard profile components for nostr identities, free-text card otherwise — sharing one card scaffold so both look identical in the Hosts & Guests strip. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/ui/note/types/PodcastEpisode.kt | 2 +- .../amethyst/ui/note/types/PodcastPeople.kt | 129 ++++++++++++++---- .../screen/loggedIn/podcasts/PodcastHeader.kt | 2 +- .../quartz/podcasts/PodcastPerson.kt | 18 +++ .../PodcastPersonSoundbiteTest.kt | 14 ++ 5 files changed, 133 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index d334023df1..2a42dff222 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -200,7 +200,7 @@ fun RenderPodcastEpisode( if (!makeItShort) { val persons = remember(noteEvent) { episode.episodePersons() } - PodcastPeople(persons, accountViewModel) + PodcastPeople(persons, accountViewModel, nav) } markdown?.takeIf { !makeItShort }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt index a08d9f0925..8d4552acfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -41,8 +42,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText @@ -50,14 +57,18 @@ import com.vitorpamplona.quartz.podcasts.PodcastPerson /** * A "Hosts & Guests" strip: the Podcasting-2.0 `podcast:person` credits for a show or episode, - * rendered as a horizontally scrollable row of avatar + name + role. A person is free-text (not a - * Nostr user), so we load their [PodcastPerson.img] with a robohash fallback seeded by their name, - * and tapping one opens their [PodcastPerson.href] link when present. + * rendered as a horizontally scrollable row of avatar + name + role. + * + * A person is usually a free-text credit (name + image URL + web link), not a Nostr user, so it's + * drawn with the app's default profile-image loader and its link opens externally. But when the + * publisher's `href` points at an `npub`/`nprofile`, we upgrade the card to a real Nostr profile — + * the standard [ClickableUserPicture] + [UsernameDisplay], tappable through to the profile. */ @Composable fun PodcastPeople( persons: List, accountViewModel: AccountViewModel, + nav: INav, ) { val people = persons.filter { it.isValid() } if (people.isEmpty()) return @@ -76,7 +87,7 @@ fun PodcastPeople( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { items(people) { person -> - PersonItem(person, accountViewModel) + PersonItem(person, accountViewModel, nav) } } } @@ -86,43 +97,101 @@ fun PodcastPeople( private fun PersonItem( person: PodcastPerson, accountViewModel: AccountViewModel, + nav: INav, +) { + val pubKey = remember(person) { person.nostrPubKey() } + + if (pubKey != null) { + LoadUser(pubKey, accountViewModel) { user -> + if (user != null) { + NostrPersonCard(user, person.role, accountViewModel, nav) + } else { + FreeTextPersonCard(person, accountViewModel) + } + } + } else { + FreeTextPersonCard(person, accountViewModel) + } +} + +/** A person that resolved to a real Nostr identity — the standard profile treatment. */ +@Composable +private fun NostrPersonCard( + user: User, + role: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + PersonCardScaffold( + onClick = { nav.nav(routeFor(user)) }, + role = role, + avatar = { ClickableUserPicture(user, 56.dp, accountViewModel) }, + name = { + UsernameDisplay( + user, + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + accountViewModel = accountViewModel, + ) + }, + ) +} + +/** A free-text `podcast:person` credit — default image loader, external link. */ +@Composable +private fun FreeTextPersonCard( + person: PodcastPerson, + accountViewModel: AccountViewModel, ) { val uriHandler = LocalUriHandler.current val href = person.href + PersonCardScaffold( + onClick = href?.let { { runCatching { uriHandler.openUri(it) } } }, + role = person.role, + avatar = { + RobohashFallbackAsyncImage( + robot = person.name, + model = person.img, + contentDescription = person.name, + modifier = Modifier.size(56.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + }, + name = { + Text( + text = person.name, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + ) +} + +/** Shared 72dp centered card layout: avatar, name, and an optional role line. */ +@Composable +private fun PersonCardScaffold( + onClick: (() -> Unit)?, + role: String?, + avatar: @Composable () -> Unit, + name: @Composable () -> Unit, +) { Column( modifier = Modifier .width(72.dp) - .then( - if (href != null) { - Modifier.clickable { runCatching { uriHandler.openUri(href) } } - } else { - Modifier - }, - ).padding(vertical = 4.dp), + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - RobohashFallbackAsyncImage( - robot = person.name, - model = person.img, - contentDescription = person.name, - modifier = Modifier.size(56.dp).clip(CircleShape), - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - ) - - Text( - text = person.name, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - - person.role?.takeIf { it.isNotEmpty() }?.let { + avatar() + name() + role?.takeIf { it.isNotEmpty() }?.let { Text( text = it, style = MaterialTheme.typography.labelSmall, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 057706340c..50c2232889 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -139,7 +139,7 @@ fun PodcastHeader( } val persons = remember(show) { show?.showPersons() ?: emptyList() } - PodcastPeople(persons, accountViewModel) + PodcastPeople(persons, accountViewModel, nav) } // Standard engagement row for the show itself (comment / zap / react), like any other diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt index f6914f9bd3..2ed1ca1e55 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.quartz.podcasts import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import kotlinx.serialization.Serializable /** @@ -46,4 +50,18 @@ class PodcastPerson( val href: String? = null, ) { fun isValid() = name.isNotBlank() + + /** + * The Nostr pubkey (hex) this person points at, when [href] is (or embeds) an `npub`/`nprofile` + * — including `nostr:` URIs and `njump.me`-style links. Null for a plain web link or no href. + * Lets a client upgrade a free-text credit to a real Nostr profile when the publisher linked one. + */ + fun nostrPubKey(): HexKey? = + href?.let { + when (val entity = Nip19Parser.uriToRoute(it)?.entity) { + is NPub -> entity.hex + is NProfile -> entity.hex + else -> null + } + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt index 536038f965..0f990e6aad 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt @@ -75,6 +75,20 @@ class PodcastPersonSoundbiteTest { assertNull(PersonTag.parse(arrayOf("person", ""))) } + @Test + fun `person href resolves an npub to a pubkey`() { + val npub = "npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y" + val hex = "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365" + assertEquals(hex, PodcastPerson(name = "Alice", href = npub).nostrPubKey()) + assertEquals(hex, PodcastPerson(name = "Alice", href = "nostr:$npub").nostrPubKey()) + } + + @Test + fun `person href that is a plain web link has no pubkey`() { + assertNull(PodcastPerson(name = "Alice", href = "https://alice.example").nostrPubKey()) + assertNull(PodcastPerson(name = "Alice", href = null).nostrPubKey()) + } + @Test fun `soundbite tag parses times and optional title`() { val soundbite = SoundbiteTag.parse(arrayOf("soundbite", "73.5", "60.0", "Best moment"))