feat(podcasts): render nostr-native podcast:person credits as real profiles

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
Claude
2026-07-01 18:15:24 +00:00
parent 429ec9177e
commit a98ec62004
5 changed files with 133 additions and 32 deletions
@@ -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 {
@@ -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<PodcastPerson>,
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,
@@ -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
@@ -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
}
}
}
@@ -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"))