Merge pull request #3200 from vitorpamplona/claude/laughing-tesla-va4nvi

Improve NIP-82 software app UI with author info and screenshots
This commit is contained in:
Vitor Pamplona
2026-06-12 15:43:28 -04:00
committed by GitHub
3 changed files with 147 additions and 52 deletions
@@ -28,8 +28,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -54,25 +56,33 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LinkIcon
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
@@ -81,6 +91,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareR
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -103,8 +114,7 @@ fun RenderSoftwareApplication(
val name = remember(event) { event.name() ?: event.appId().orEmpty() }
val summary = remember(event) { event.summary() }
val description = remember(event) { event.content.trim() }
val platforms = remember(event) { event.platforms() }
val license = remember(event) { event.license() }
val images = remember(event) { event.images() }
val latestVersion by produceLatestReleaseVersion(event)
@@ -133,6 +143,7 @@ fun RenderSoftwareApplication(
overflow = TextOverflow.Ellipsis,
)
}
AppAuthorLine(note, accountViewModel, nav)
summary?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
@@ -160,9 +171,9 @@ fun RenderSoftwareApplication(
)
}
if (platforms.isNotEmpty() || license != null) {
if (images.isNotEmpty()) {
Spacer(StdVertSpacer)
PlatformLicenseRow(platforms = platforms, license = license)
ScreenshotsStrip(images, accountViewModel, imageHeight = 200.dp)
}
}
@@ -231,12 +242,24 @@ fun AppIcon(
name: String,
sizeDp: Int = 56,
) {
val shape = RoundedCornerShape((sizeDp / 4).dp)
Box(
Modifier
.size(sizeDp.dp)
.clip(RoundedCornerShape((sizeDp / 4).dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape((sizeDp / 4).dp)),
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, shape),
contentAlignment = Alignment.Center,
) {
// Fallback underneath the image: visible when there is no icon url,
// while the icon downloads, and when the download fails (AsyncImage
// draws nothing in those states).
Text(
text = (name.firstOrNull() ?: '?').uppercase(),
fontSize = (sizeDp / 2).sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.grayText,
)
icon?.let {
AsyncImage(
model = it,
@@ -248,6 +271,29 @@ fun AppIcon(
}
}
/**
* "by <author>" line with a small clickable profile picture. Shared between
* the app feed card and the app detail screen header.
*/
@Composable
fun AppAuthorLine(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringRes(R.string.nip82_by_author),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
Spacer(Modifier.width(4.dp))
NoteAuthorPicture(note, Size20dp, accountViewModel = accountViewModel, nav = nav)
Spacer(Modifier.width(4.dp))
NoteUsernameDisplay(note, Modifier.weight(1f, fill = false), accountViewModel = accountViewModel)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PlatformLicenseRow(
@@ -284,23 +330,44 @@ fun TopicChipFlow(
}
@Composable
fun ScreenshotsStrip(images: List<String>) {
fun ScreenshotsStrip(
images: List<String>,
accountViewModel: AccountViewModel,
contentPadding: PaddingValues = PaddingValues(0.dp),
imageHeight: Dp = 180.dp,
) {
if (images.isEmpty()) return
val mediaContents =
remember(images) {
images.map { MediaUrlImage(url = it) }.toImmutableList()
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.height(180.dp),
contentPadding = contentPadding,
modifier = Modifier.height(imageHeight),
) {
items(images) { imageUrl ->
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier =
Modifier
.height(180.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp)),
)
items(mediaContents) { content ->
// Fixed-height tile whose width follows the image's aspect ratio
// once it is known; assumes a portrait phone screenshot before the
// first load fills the ratio cache.
val ratio = MediaAspectRatioCache.get(content.url) ?: (9f / 16f)
Box(
Modifier
.height(imageHeight)
.aspectRatio(ratio)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp)),
) {
ZoomableContentView(
content = content,
images = mediaContents,
roundedCorner = false,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -54,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
@@ -66,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.types.AppAuthorLine
import com.vitorpamplona.amethyst.ui.note.types.AppIcon
import com.vitorpamplona.amethyst.ui.note.types.AppLinksColumn
import com.vitorpamplona.amethyst.ui.note.types.Chip
@@ -83,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.Thre
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@@ -199,17 +203,20 @@ private fun SoftwareAppDetailBody(
) {
item(key = "header") {
AppDetailHeader(
note = note,
icon = icon,
name = name,
summary = summary,
latestVersion = latestRelease?.version(),
accountViewModel = accountViewModel,
nav = nav,
)
}
if (images.isNotEmpty()) {
item(key = "screenshots") {
Spacer(Modifier.height(12.dp))
ScreenshotsStrip(images)
ScreenshotsStrip(images, accountViewModel, contentPadding = PaddingValues(horizontal = 12.dp))
}
}
@@ -252,28 +259,18 @@ private fun SoftwareAppDetailBody(
}
}
item(key = "reactions") {
Spacer(Modifier.height(12.dp))
ReactionsRow(
baseNote = note,
showReactionDetail = true,
addPadding = false,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (latestRelease != null) {
item(key = "latest-release") {
Spacer(Modifier.height(12.dp))
SectionLabel(stringRes(R.string.nip82_section_latest_release))
RenderSoftwareReleaseBody(
event = latestRelease,
accountViewModel = accountViewModel,
nav = nav,
showAppId = false,
)
Column(PaddingHorizontal12Modifier) {
SectionLabel(stringRes(R.string.nip82_section_latest_release))
RenderSoftwareReleaseBody(
event = latestRelease,
accountViewModel = accountViewModel,
nav = nav,
showAppId = false,
)
}
}
}
@@ -292,25 +289,39 @@ private fun SoftwareAppDetailBody(
key = { "older-${it.id}" },
) { release ->
Spacer(Modifier.height(8.dp))
RenderSoftwareReleaseBody(
event = release,
accountViewModel = accountViewModel,
nav = nav,
showAppId = false,
)
Column(PaddingHorizontal12Modifier) {
RenderSoftwareReleaseBody(
event = release,
accountViewModel = accountViewModel,
nav = nav,
showAppId = false,
)
}
}
}
}
item(key = "comments-header") {
Spacer(Modifier.height(16.dp))
SectionLabel(stringRes(R.string.nip82_section_comments))
if (comments.isEmpty()) {
Spacer(Modifier.height(4.dp))
item(key = "reactions") {
Spacer(Modifier.height(12.dp))
ReactionsRow(
baseNote = note,
showReactionDetail = true,
addPadding = true,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(thickness = DividerThickness)
}
if (comments.isEmpty()) {
item(key = "no-comments") {
Spacer(Modifier.height(8.dp))
Text(
text = stringRes(R.string.nip82_no_comments),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
modifier = PaddingHorizontal12Modifier,
)
}
}
@@ -320,8 +331,16 @@ private fun SoftwareAppDetailBody(
key = { _, item -> item.idHex },
contentType = { _, _ -> "comment" },
) { _, item ->
val level = threadViewModel.levelFlowForItem(item).collectAsStateWithLifecycle(0)
NoteCompose(
baseNote = item,
modifier =
Modifier.drawReplyLevel(
level = level,
color = MaterialTheme.colorScheme.placeholderText,
selected = MaterialTheme.colorScheme.placeholderText,
),
isBoostedNote = false,
unPackReply = ReplyRenderType.NONE,
quotesLeft = 3,
@@ -335,12 +354,18 @@ private fun SoftwareAppDetailBody(
@Composable
private fun AppDetailHeader(
note: AddressableNote,
icon: String?,
name: String,
summary: String?,
latestVersion: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = PaddingHorizontal12Modifier,
) {
AppIcon(icon = icon, name = name, sizeDp = 72)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
@@ -351,6 +376,7 @@ private fun AppDetailHeader(
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
AppAuthorLine(note, accountViewModel, nav)
summary?.takeIf { it.isNotBlank() }?.let {
Spacer(StdVertSpacer)
Text(
@@ -374,7 +400,7 @@ private fun Section(
title: String,
content: @Composable () -> Unit,
) {
Column {
Column(PaddingHorizontal12Modifier) {
SectionLabel(title)
Spacer(Modifier.height(6.dp))
content()
@@ -401,6 +427,7 @@ private fun OlderReleasesToggle(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
.clickable { onToggle() }
+1
View File
@@ -721,6 +721,7 @@
<string name="nip82_section_latest_release">Latest release</string>
<string name="nip82_section_comments">Comments</string>
<string name="nip82_no_comments">Be the first to comment.</string>
<string name="nip82_by_author">by</string>
<string name="nip82_older_releases_show">Show older releases</string>
<string name="nip82_older_releases_hide">Hide older releases</string>
<string name="calendars">Calendars</string>