mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user