diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 9973b10b1b..a6848bdcf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { SoftwareAppsScreen(accountViewModel, nav) } + composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } composableFromEnd { CalendarReminderSettingsScreen(nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 212ffb347f..e886de43b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 7a9c63cbd0..0c8ef1fe7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index b49e215c3d..2dd2480dfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -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(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 { + 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, - topics: List, - 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, + 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, + 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) { + 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt new file mode 100644 index 0000000000..542d091ba7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppDetailScreen.kt @@ -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(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 = + 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()) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index de109dde84..e5665979e9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -593,6 +593,15 @@ Source: %1$s v%1$s Download + About + Platforms + Topics + Links + Latest release + Comments + Be the first to comment. + Show older releases + Hide older releases Calendars Shorts Public Chats