Merge pull request #3078 from vitorpamplona/claude/pensive-turing-6UC1W

Add dedicated NIP-82 software app detail screen
This commit is contained in:
Vitor Pamplona
2026-05-27 18:38:03 -04:00
committed by GitHub
6 changed files with 702 additions and 100 deletions
@@ -186,6 +186,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
@@ -265,6 +266,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -149,6 +150,10 @@ fun routeForInner(
Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is SoftwareApplicationEvent -> {
Route.SoftwareAppDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
// Calendar appointments route to their dedicated detail screen rather than the generic
// Route.Note that AddressableEvent would fall through to — without this the notification
// tap and `nostr:naddr…` deep links land on the bare note view instead of the calendar
@@ -83,6 +83,18 @@ sealed class Route {
@Serializable object SoftwareApps : Route()
@Serializable data class SoftwareAppDetail(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable object Calendars : Route()
@Serializable object CalendarCollections : Route()
@@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -55,11 +58,15 @@ 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.model.LocalCache
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.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LinkIcon
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
@@ -73,10 +80,16 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent
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.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* NIP-82 kind 32267 — Software Application card. Renders icon, name, summary,
* a horizontal screenshot strip, hashtag/platform chips, and quick links.
* NIP-82 kind 32267 — compact feed card. Renders icon, name, latest version
* chip, summary, description, and platforms/license. Tapping the card opens
* the dedicated [Route.SoftwareAppDetail] screen with screenshots, full
* description, links, releases, and comments. The bottom of the card hosts
* the standard [ReactionsRow] so zaps / likes / replies are visible inline.
*/
@Composable
fun RenderSoftwareApplication(
@@ -86,17 +99,15 @@ fun RenderSoftwareApplication(
) {
val event = note.event as? SoftwareApplicationEvent ?: return
val uri = LocalUriHandler.current
val icon = remember(event) { event.icon() }
val name = remember(event) { event.name() ?: event.appId().orEmpty() }
val summary = remember(event) { event.summary() }
val images = remember(event) { event.images() }
val topics = remember(event) { event.topics() }
val description = remember(event) { event.content.trim() }
val platforms = remember(event) { event.platforms() }
val website = remember(event) { event.url() }
val repo = remember(event) { event.repository() }
val license = remember(event) { event.license() }
val latestVersion by produceLatestReleaseVersion(event)
Column(
modifier =
Modifier
@@ -104,24 +115,11 @@ fun RenderSoftwareApplication(
.padding(top = Size5dp)
.clip(QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
.clickable { nav.nav(Route.SoftwareAppDetail(event.kind, event.pubKey, event.dTag())) }
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(56.dp)
.clip(RoundedCornerShape(12.dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(12.dp)),
) {
icon?.let {
AsyncImage(
model = it,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp),
)
}
}
AppIcon(icon = icon, name = name)
Spacer(Modifier.width(12.dp))
@@ -135,7 +133,7 @@ fun RenderSoftwareApplication(
overflow = TextOverflow.Ellipsis,
)
}
summary?.let {
summary?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
@@ -145,83 +143,215 @@ fun RenderSoftwareApplication(
)
}
}
}
if (images.isNotEmpty()) {
Spacer(StdVertSpacer)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.height(180.dp),
) {
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)),
)
}
latestVersion?.let { version ->
Spacer(Modifier.width(8.dp))
VersionChip(version)
}
}
if (platforms.isNotEmpty() || topics.isNotEmpty() || license != null) {
if (description.isNotBlank()) {
Spacer(StdVertSpacer)
ChipFlowRow(platforms = platforms, topics = topics, license = license)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
if (website != null || repo != null) {
if (platforms.isNotEmpty() || license != null) {
Spacer(StdVertSpacer)
Column {
website?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
ClickableTextPrimary(
text = it.removePrefix("https://").removePrefix("http://"),
onClick = { runCatching { uri.openUri(it) } },
modifier = Modifier.padding(start = 5.dp),
)
}
}
repo?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
ClickableTextPrimary(
text = stringRes(R.string.nip82_repository_label, it.removePrefix("https://").removePrefix("http://")),
onClick = { runCatching { uri.openUri(it) } },
modifier = Modifier.padding(start = 5.dp),
)
}
}
PlatformLicenseRow(platforms = platforms, license = license)
}
}
ReactionsRow(
baseNote = note,
showReactionDetail = true,
addPadding = true,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* Looks up the latest NIP-82 [SoftwareReleaseEvent] for [app] from
* [LocalCache] and exposes the version string. Recomputes on event identity
* change; relay-driven recompositions of the surrounding feed will pick up
* newer releases via re-keying.
*/
@Composable
fun produceLatestReleaseVersion(app: SoftwareApplicationEvent) =
produceState<String?>(initialValue = null, key1 = app.id) {
value =
withContext(Dispatchers.Default) {
findLatestNip82Release(app)?.version()
}
}
fun findLatestNip82Release(app: SoftwareApplicationEvent): SoftwareReleaseEvent? {
val prefix = "${app.dTag()}@"
val notes =
LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr ->
val ev = addr.event ?: return@filterIntoSet false
ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)
}
}
return notes
.mapNotNull {
when (val ev = it.event) {
is SoftwareReleaseEvent -> ev
null -> null
else -> if (ev.isNip82SoftwareRelease()) ev.asSoftwareRelease() else null
}
}.maxByOrNull { it.createdAt }
}
fun findAllNip82Releases(app: SoftwareApplicationEvent): List<SoftwareReleaseEvent> {
val prefix = "${app.dTag()}@"
val notes =
LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr ->
val ev = addr.event ?: return@filterIntoSet false
ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)
}
return notes
.mapNotNull {
when (val ev = it.event) {
is SoftwareReleaseEvent -> ev
null -> null
else -> if (ev.isNip82SoftwareRelease()) ev.asSoftwareRelease() else null
}
}.sortedByDescending { it.createdAt }
}
@Composable
private fun ChipFlowRow(
platforms: List<String>,
topics: List<String>,
license: String?,
) {
// FlowRow is in compose foundation but we use a simple LazyRow to keep this compatible.
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(platforms) { Chip(it) }
items(topics) { Chip("#$it") }
license?.let { item { Chip(it, tint = MaterialTheme.colorScheme.secondaryContainer) } }
}
}
@Composable
private fun Chip(
text: String,
tint: Color = MaterialTheme.colorScheme.surfaceVariant,
fun AppIcon(
icon: String?,
name: String,
sizeDp: Int = 56,
) {
Box(
Modifier
.size(sizeDp.dp)
.clip(RoundedCornerShape((sizeDp / 4).dp))
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape((sizeDp / 4).dp)),
) {
icon?.let {
AsyncImage(
model = it,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(sizeDp.dp),
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PlatformLicenseRow(
platforms: List<String>,
license: String?,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
platforms.forEach { Chip(it) }
license?.let { Chip(it, tint = MaterialTheme.colorScheme.secondaryContainer) }
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TopicChipFlow(
topics: List<String>,
nav: INav,
) {
if (topics.isEmpty()) return
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
topics.forEach { tag ->
Chip(
text = "#$tag",
modifier = Modifier.clickable { nav.nav(Route.Hashtag(tag.lowercase())) },
)
}
}
}
@Composable
fun ScreenshotsStrip(images: List<String>) {
if (images.isEmpty()) return
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.height(180.dp),
) {
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)),
)
}
}
}
@Composable
fun AppLinksColumn(
website: String?,
repository: String?,
) {
if (website == null && repository == null) return
val uri = LocalUriHandler.current
Column {
website?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
ClickableTextPrimary(
text = it.removePrefix("https://").removePrefix("http://"),
onClick = { runCatching { uri.openUri(it) } },
modifier = Modifier.padding(start = 5.dp),
)
}
}
repository?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
ClickableTextPrimary(
text = stringRes(R.string.nip82_repository_label, it.removePrefix("https://").removePrefix("http://")),
onClick = { runCatching { uri.openUri(it) } },
modifier = Modifier.padding(start = 5.dp),
)
}
}
}
}
@Composable
fun VersionChip(version: String) {
Chip(
text = stringRes(R.string.nip82_version_label, version),
tint = MaterialTheme.colorScheme.primaryContainer,
)
}
@Composable
fun Chip(
text: String,
tint: Color = MaterialTheme.colorScheme.surfaceVariant,
modifier: Modifier = Modifier,
) {
Box(
modifier
.clip(RoundedCornerShape(12.dp))
.background(tint)
.padding(horizontal = 8.dp, vertical = 4.dp),
@@ -254,11 +384,21 @@ fun RenderSoftwareRelease(
return
}
RenderSoftwareReleaseBody(event = event, accountViewModel = accountViewModel, nav = nav)
}
@Composable
fun RenderSoftwareReleaseBody(
event: SoftwareReleaseEvent,
accountViewModel: AccountViewModel,
nav: INav,
showAppId: Boolean = true,
) {
val appId = remember(event) { event.appId() }
val version = remember(event) { event.version() }
val channel = remember(event) { event.channel() }
val assets = remember(event) { event.assets() }
val notes = remember(event) { event.content }
val notes = event.content
Column(
modifier =
@@ -271,14 +411,16 @@ fun RenderSoftwareRelease(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
appId?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (showAppId) {
appId?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
version?.let {
Text(
@@ -354,6 +496,7 @@ private fun LoadAssetNote(
content(note)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SoftwareAssetRow(
note: Note,
@@ -416,8 +559,11 @@ private fun SoftwareAssetRow(
}
if (platforms.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(platforms) { Chip(it) }
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
platforms.forEach { Chip(it) }
}
}
}
@@ -435,6 +581,7 @@ private fun SoftwareAssetRow(
* NIP-82 kind 3063 — Software Asset card. A compact descriptor of a single
* install artifact: MIME type, version, size, platforms, and a download link.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderSoftwareAsset(
note: Note,
@@ -499,15 +646,18 @@ fun RenderSoftwareAsset(
if (mimeType != null || platforms.isNotEmpty()) {
Spacer(StdVertSpacer)
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
mimeType?.let { item { Chip(prettyMime(it)) } }
items(platforms) { Chip(it) }
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
mimeType?.let { Chip(prettyMime(it)) }
platforms.forEach { Chip(it) }
}
}
}
}
private fun prettyMime(mime: String): String =
internal fun prettyMime(mime: String): String =
when (mime) {
"application/vnd.android.package-archive" -> "APK"
"application/vnd.apple.ipa" -> "IPA"
@@ -528,7 +678,7 @@ private fun prettyMime(mime: String): String =
else -> mime
}
private fun formatBytes(bytes: Long): String {
internal fun formatBytes(bytes: Long): String {
if (bytes < 1024L) return "$bytes B"
val kb = bytes / 1024.0
if (kb < 1024) return "%.1f KB".format(kb)
@@ -0,0 +1,424 @@
/*
* 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.softwareapps
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
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.AppIcon
import com.vitorpamplona.amethyst.ui.note.types.AppLinksColumn
import com.vitorpamplona.amethyst.ui.note.types.Chip
import com.vitorpamplona.amethyst.ui.note.types.PlatformLicenseRow
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareReleaseBody
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.ScreenshotsStrip
import com.vitorpamplona.amethyst.ui.note.types.TopicChipFlow
import com.vitorpamplona.amethyst.ui.note.types.VersionChip
import com.vitorpamplona.amethyst.ui.note.types.findAllNip82Releases
import com.vitorpamplona.amethyst.ui.note.types.findLatestNip82Release
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssemblerSubscription
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.QuoteBorder
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
import com.vitorpamplona.quartz.nip01Core.core.Address
@Composable
fun SoftwareAppDetailScreen(
address: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadAddressableNote(address, accountViewModel) { note ->
note?.let {
SoftwareAppDetailScreenContent(
note = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun SoftwareAppDetailScreenContent(
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event by observeNoteEvent<SoftwareApplicationEvent>(note, accountViewModel)
// Drive the comments thread off the app's address tag — ThreadAssembler
// resolves that to the application note and then walks replies.
val addressTag = note.idHex
val threadViewModel: ThreadFeedViewModel =
viewModel(
key = addressTag + "SoftwareAppDetailThread",
factory = ThreadFeedViewModel.Factory(accountViewModel.account, addressTag),
)
WatchLifecycleAndUpdateModel(threadViewModel)
ThreadFilterAssemblerSubscription(addressTag, accountViewModel)
EventFinderFilterAssemblerSubscription(note, accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarWithBackButton(
caption = event?.name() ?: event?.appId() ?: note.dTag(),
nav = nav,
)
},
accountViewModel = accountViewModel,
) {
val current = event
if (current == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = stringRes(R.string.loading_feed),
color = MaterialTheme.colorScheme.placeholderText,
)
}
} else {
SoftwareAppDetailBody(
note = note,
event = current,
threadViewModel = threadViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun SoftwareAppDetailBody(
note: AddressableNote,
event: SoftwareApplicationEvent,
threadViewModel: ThreadFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val icon = remember(event) { event.icon() }
val name = remember(event) { event.name() ?: event.appId().orEmpty() }
val summary = remember(event) { event.summary() }
val description = remember(event) { event.content.trim() }
val images = remember(event) { event.images() }
val topics = remember(event) { event.topics() }
val platforms = remember(event) { event.platforms() }
val license = remember(event) { event.license() }
val website = remember(event) { event.url() }
val repo = remember(event) { event.repository() }
val latestRelease = remember(event) { findLatestNip82Release(event) }
val olderReleases = remember(event) { findAllNip82Releases(event).drop(1) }
val threadState by threadViewModel.feedState.feedContent.collectAsStateWithLifecycle()
val comments: List<Note> =
when (val s = threadState) {
is FeedState.Loaded ->
s.feed
.collectAsStateWithLifecycle()
.value.list
.filter { it.idHex != note.idHex }
else -> emptyList()
}
var showOlder by rememberSaveable(event.id) { mutableStateOf(false) }
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = threadViewModel.llState,
) {
item(key = "header") {
AppDetailHeader(
icon = icon,
name = name,
summary = summary,
latestVersion = latestRelease?.version(),
)
}
if (images.isNotEmpty()) {
item(key = "screenshots") {
Spacer(Modifier.height(12.dp))
ScreenshotsStrip(images)
}
}
if (description.isNotBlank()) {
item(key = "about") {
Spacer(Modifier.height(12.dp))
Section(title = stringRes(R.string.nip82_section_about)) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
if (platforms.isNotEmpty() || license != null) {
item(key = "platforms") {
Spacer(Modifier.height(12.dp))
Section(title = stringRes(R.string.nip82_section_platforms)) {
PlatformLicenseRow(platforms = platforms, license = license)
}
}
}
if (topics.isNotEmpty()) {
item(key = "topics") {
Spacer(Modifier.height(12.dp))
Section(title = stringRes(R.string.nip82_section_topics)) {
TopicChipFlow(topics = topics, nav = nav)
}
}
}
if (website != null || repo != null) {
item(key = "links") {
Spacer(Modifier.height(12.dp))
Section(title = stringRes(R.string.nip82_section_links)) {
AppLinksColumn(website = website, repository = repo)
}
}
}
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,
)
}
}
if (olderReleases.isNotEmpty()) {
item(key = "older-releases-toggle") {
Spacer(Modifier.height(8.dp))
OlderReleasesToggle(
count = olderReleases.size,
expanded = showOlder,
onToggle = { showOlder = !showOlder },
)
}
if (showOlder) {
items(
olderReleases,
key = { "older-${it.id}" },
) { release ->
Spacer(Modifier.height(8.dp))
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))
Text(
text = stringRes(R.string.nip82_no_comments),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
itemsIndexed(
comments,
key = { _, item -> item.idHex },
contentType = { _, _ -> "comment" },
) { _, item ->
NoteCompose(
baseNote = item,
isBoostedNote = false,
unPackReply = ReplyRenderType.NONE,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(thickness = DividerThickness)
}
}
}
@Composable
private fun AppDetailHeader(
icon: String?,
name: String,
summary: String?,
latestVersion: String?,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
AppIcon(icon = icon, name = name, sizeDp = 72)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
summary?.takeIf { it.isNotBlank() }?.let {
Spacer(StdVertSpacer)
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
latestVersion?.let {
Spacer(Modifier.width(8.dp))
VersionChip(it)
}
}
}
@Composable
private fun Section(
title: String,
content: @Composable () -> Unit,
) {
Column {
SectionLabel(title)
Spacer(Modifier.height(6.dp))
content()
}
}
@Composable
private fun SectionLabel(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.grayText,
fontWeight = FontWeight.SemiBold,
)
}
@Composable
private fun OlderReleasesToggle(
count: Int,
expanded: Boolean,
onToggle: () -> Unit,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
.clickable { onToggle() }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text =
if (expanded) {
stringRes(R.string.nip82_older_releases_hide)
} else {
stringRes(R.string.nip82_older_releases_show)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
Chip(text = count.toString())
}
}
}
+9
View File
@@ -593,6 +593,15 @@
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Download</string>
<string name="nip82_section_about">About</string>
<string name="nip82_section_platforms">Platforms</string>
<string name="nip82_section_topics">Topics</string>
<string name="nip82_section_links">Links</string>
<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_older_releases_show">Show older releases</string>
<string name="nip82_older_releases_hide">Hide older releases</string>
<string name="calendars">Calendars</string>
<string name="shorts">Shorts</string>
<string name="public_chats">Public Chats</string>