diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 47096c4d06..74a41668bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.Publ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler @@ -122,6 +123,7 @@ class RelaySubscriptionsCoordinator( val nestRoomLiveness = NestRoomLivenessAssembler(client) val longs = LongsFilterAssembler(client) val articles = ArticlesFilterAssembler(client) + val softwareApps = SoftwareAppsFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) val browseEmojiSets = BrowseEmojiSetsFilterAssembler(client) @@ -154,6 +156,7 @@ class RelaySubscriptionsCoordinator( nestRoomLiveness, longs, articles, + softwareApps, badges, profileBadges, browseEmojiSets, 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 09d9dce793..df8de1d607 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 @@ -182,6 +182,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.SoftwareAppsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen @@ -259,6 +260,7 @@ fun BuildNavigation( composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } + composableFromEnd { SoftwareAppsScreen(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/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 95bbaf8d8b..735b669e1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -50,6 +50,7 @@ enum class NavBarItem { COMMUNITIES, ARTICLES, PICTURES, + SOFTWARE_APPS, CALENDARS, CALENDAR_COLLECTIONS, SHORTS, @@ -201,6 +202,13 @@ val NavBarCatalog: Map = icon = MaterialSymbols.Photo, resolveRoute = { Route.Pictures }, ), + NavBarItem.SOFTWARE_APPS to + NavBarItemDef( + id = NavBarItem.SOFTWARE_APPS, + labelRes = R.string.software_apps, + icon = MaterialSymbols.Apps, + resolveRoute = { Route.SoftwareApps }, + ), NavBarItem.CALENDARS to NavBarItemDef( id = NavBarItem.CALENDARS, @@ -334,6 +342,7 @@ val DrawerFeedsItems: List = NavBarItem.COMMUNITIES, NavBarItem.ARTICLES, NavBarItem.PICTURES, + NavBarItem.SOFTWARE_APPS, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SHORTS, 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 6b68a5ee88..b1dbad770f 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 @@ -81,6 +81,8 @@ sealed class Route { @Serializable object Pictures : Route() + @Serializable object SoftwareApps : Route() + @Serializable object Calendars : Route() @Serializable object CalendarCollections : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a97e583caa..8ee29194a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -167,6 +167,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember import com.vitorpamplona.amethyst.ui.note.types.RenderReport import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication +import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset +import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderThread @@ -212,6 +215,9 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent @@ -256,6 +262,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent @@ -870,6 +877,23 @@ private fun RenderNoteRow( RenderAppDefinition(baseNote, accountViewModel, nav) } + is SoftwareApplicationEvent -> { + RenderSoftwareApplication(baseNote, accountViewModel, nav) + } + + is SoftwareAssetEvent -> { + RenderSoftwareAsset(baseNote, accountViewModel, nav) + } + + is ReleaseArtifactSetEvent -> { + // kind 30063 is used by both NIP-51 (ReleaseArtifactSet) and NIP-82 (SoftwareRelease). + // The EventFactory always materializes the NIP-51 class; dispatch to NIP-82 when the + // tag signature matches. + if (noteEvent.isNip82SoftwareRelease()) { + RenderSoftwareRelease(baseNote, accountViewModel, nav) + } + } + is AttestationEvent -> { RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } 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 new file mode 100644 index 0000000000..b49e215c3d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -0,0 +1,539 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +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.layout.ContentScale +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.sp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +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.note.LinkIcon +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.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +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.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease + +/** + * NIP-82 kind 32267 — Software Application card. Renders icon, name, summary, + * a horizontal screenshot strip, hashtag/platform chips, and quick links. + */ +@Composable +fun RenderSoftwareApplication( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + 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 platforms = remember(event) { event.platforms() } + val website = remember(event) { event.url() } + val repo = remember(event) { event.repository() } + val license = remember(event) { event.license() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = Size5dp) + .clip(QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder) + .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), + ) + } + } + + Spacer(Modifier.width(12.dp)) + + Column(Modifier.weight(1f)) { + if (name.isNotBlank()) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + 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)), + ) + } + } + } + + if (platforms.isNotEmpty() || topics.isNotEmpty() || license != null) { + Spacer(StdVertSpacer) + ChipFlowRow(platforms = platforms, topics = topics, license = license) + } + + if (website != null || repo != 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), + ) + } + } + } + } + } +} + +@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, +) { + Box( + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(tint) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text(text = text, fontSize = 11.sp, style = MaterialTheme.typography.labelSmall) + } +} + +/** + * NIP-82 kind 30063 — Software Release card. Renders the version and channel as + * a header, the aggregated platforms as chips, the release notes (markdown), and + * a count of bundled assets. + */ +@Composable +fun RenderSoftwareRelease( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + // kind 30063 is shared between NIP-51 ReleaseArtifactSetEvent and NIP-82 SoftwareReleaseEvent; + // EventFactory always returns the NIP-51 class, so we re-parse the same tag array as the + // NIP-82 form when the tag signature matches. + val rawEvent = note.event ?: return + val event: SoftwareReleaseEvent = + if (rawEvent is SoftwareReleaseEvent) { + rawEvent + } else if (rawEvent.isNip82SoftwareRelease()) { + rawEvent.asSoftwareRelease() + } else { + return + } + + 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 } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = Size5dp) + .clip(QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder) + .padding(12.dp), + ) { + 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, + ) + } + version?.let { + Text( + text = stringRes(R.string.nip82_version_label, it), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } + channel?.let { + Chip(it.uppercase(), tint = MaterialTheme.colorScheme.tertiaryContainer) + } + } + + if (notes.isNotBlank()) { + Spacer(StdVertSpacer) + Text( + text = notes, + style = MaterialTheme.typography.bodyMedium, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + ) + } + + if (assets.isNotEmpty()) { + Spacer(StdVertSpacer) + val n = assets.size + Text( + text = pluralStringResource(R.plurals.nip82_assets_count, n, n), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(Modifier.height(6.dp)) + BundledAssetsList( + assetIds = assets.map { it.eventId }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun BundledAssetsList( + assetIds: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + assetIds.forEach { id -> + key(id) { + LoadAssetNote(id, accountViewModel) { assetNote -> + if (assetNote != null) { + SoftwareAssetRow(assetNote, accountViewModel) + } + } + } + } + } +} + +@Composable +private fun LoadAssetNote( + eventId: String, + accountViewModel: AccountViewModel, + content: @Composable (Note?) -> Unit, +) { + val note by produceState(initialValue = accountViewModel.getNoteIfExists(eventId), eventId) { + if (value == null) { + value = accountViewModel.checkGetOrCreateNote(eventId) + } + } + content(note) +} + +@Composable +private fun SoftwareAssetRow( + note: Note, + accountViewModel: AccountViewModel, +) { + // Subscribe so a missing asset event is fetched from the relay and we recompose when it arrives. + val eventState = observeNoteEvent(note, accountViewModel) + val event = eventState.value ?: return + + val uri = LocalUriHandler.current + val version = remember(event) { event.version() } + val mimeType = remember(event) { event.mimeType() } + val sizeBytes = remember(event) { event.sizeInBytes() } + val platforms = remember(event) { event.platforms() } + val downloadUrl = remember(event) { event.url() } + val variant = remember(event) { event.variant() } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + mimeType?.let { + Text( + text = prettyMime(it), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(6.dp)) + } + version?.let { + Text( + text = stringRes(R.string.nip82_version_label, it), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + variant?.let { + Spacer(Modifier.width(6.dp)) + Text( + text = "· $it", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + sizeBytes?.let { + Spacer(Modifier.width(6.dp)) + Text( + text = "· ${formatBytes(it.toLong())}", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + } + if (platforms.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + items(platforms) { Chip(it) } + } + } + } + downloadUrl?.let { + Spacer(Modifier.width(8.dp)) + ClickableTextPrimary( + text = stringRes(R.string.nip82_download), + onClick = { runCatching { uri.openUri(it) } }, + ) + } + } +} + +/** + * NIP-82 kind 3063 — Software Asset card. A compact descriptor of a single + * install artifact: MIME type, version, size, platforms, and a download link. + */ +@Composable +fun RenderSoftwareAsset( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? SoftwareAssetEvent ?: return + + val uri = LocalUriHandler.current + val appId = remember(event) { event.appId() } + val version = remember(event) { event.version() } + val mimeType = remember(event) { event.mimeType() } + val sizeBytes = remember(event) { event.sizeInBytes() } + val platforms = remember(event) { event.platforms() } + val downloadUrl = remember(event) { event.url() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = Size5dp) + .clip(QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder) + .padding(12.dp), + ) { + 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, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + version?.let { + Text( + text = stringRes(R.string.nip82_version_label, it), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + sizeBytes?.let { + Spacer(Modifier.width(6.dp)) + Text( + text = "· ${formatBytes(it.toLong())}", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + } + } + downloadUrl?.let { + ClickableTextPrimary( + text = stringRes(R.string.nip82_download), + onClick = { runCatching { uri.openUri(it) } }, + ) + } + } + + if (mimeType != null || platforms.isNotEmpty()) { + Spacer(StdVertSpacer) + LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + mimeType?.let { item { Chip(prettyMime(it)) } } + items(platforms) { Chip(it) } + } + } + } +} + +private fun prettyMime(mime: String): String = + when (mime) { + "application/vnd.android.package-archive" -> "APK" + "application/vnd.apple.ipa" -> "IPA" + "application/x-apple-diskimage" -> "DMG" + "application/vnd.apple.installer+xml" -> "PKG" + "application/x-msi" -> "MSI" + "application/vnd.appimage" -> "AppImage" + "application/vnd.flatpak" -> "Flatpak" + "application/vnd.oci.image.manifest.v1+json" -> "OCI" + "application/x-executable" -> "ELF" + "application/x-mach-binary" -> "Mach-O" + "application/vnd.microsoft.portable-executable" -> "EXE" + "application/vsix" -> "VSIX" + "application/x-chrome-extension" -> "CRX" + "application/x-xpinstall" -> "XPI" + "application/wasm" -> "WASM" + "application/webbundle" -> "Web Bundle" + else -> mime + } + +private fun formatBytes(bytes: Long): String { + if (bytes < 1024L) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return "%.1f KB".format(kb) + val mb = kb / 1024 + if (mb < 1024) return "%.1f MB".format(mb) + val gb = mb / 1024 + return "%.2f GB".format(gb) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 525f56eacc..42555a177c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.dal.ProductsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.dal.PublicChatsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.dal.SoftwareAppsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter import kotlinx.coroutines.CoroutineScope @@ -111,6 +112,7 @@ class AccountFeedContentStates( val nestsFeed = FeedContentState(NestsFeedFilter(account), scope, LocalCache) val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache) val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache) + val softwareAppsFeed = FeedContentState(SoftwareAppsFeedFilter(account), scope, LocalCache) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) val notificationsFollowing = CardFeedContentState(NotificationFeedFilter(account, TopFilter.AllFollows), scope) @@ -199,6 +201,7 @@ class AccountFeedContentStates( nestsFeed.updateFeedWith(newNotes) longsFeed.updateFeedWith(newNotes) articlesFeed.updateFeedWith(newNotes) + softwareAppsFeed.updateFeedWith(newNotes) calendarAppointmentsFeed.updateFeedWith(newNotes) calendarCollectionsFeed.updateFeedWith(newNotes) @@ -254,6 +257,7 @@ class AccountFeedContentStates( nestsFeed.deleteFromFeed(newNotes) longsFeed.deleteFromFeed(newNotes) articlesFeed.deleteFromFeed(newNotes) + softwareAppsFeed.deleteFromFeed(newNotes) calendarAppointmentsFeed.deleteFromFeed(newNotes) calendarCollectionsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 74f195431b..8af58972f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilte import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription /** @@ -86,6 +87,8 @@ private fun PreloadFor( NavBarItem.PICTURES -> PicturesFilterAssemblerSubscription(accountViewModel) + NavBarItem.SOFTWARE_APPS -> SoftwareAppsFilterAssemblerSubscription(accountViewModel) + NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, -> CalendarsFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppsScreen.kt new file mode 100644 index 0000000000..02798e9fcc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/SoftwareAppsScreen.kt @@ -0,0 +1,142 @@ +/* + * 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.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +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.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent + +@Composable +fun SoftwareAppsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + SoftwareAppsScreen( + feedContentState = accountViewModel.feedStates.softwareAppsFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun SoftwareAppsScreen( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedContentState) + SoftwareAppsFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + UserDrawerSearchTopBar(accountViewModel, nav) {} + }, + bottomBar = { + AppBottomBar(Route.SoftwareApps, nav, accountViewModel) { route -> + if (route == Route.SoftwareApps) { + feedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(feedContentState, true) { + SaveableFeedContentState(feedContentState) { listState -> + RenderFeedContentState( + feedContentState = feedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "SoftwareAppsFeed", + onLoaded = { loaded -> + SoftwareAppsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun SoftwareAppsFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + if (item.event is SoftwareApplicationEvent) { + RenderSoftwareApplication( + note = item, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider(thickness = DividerThickness) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/dal/SoftwareAppsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/dal/SoftwareAppsFeedFilter.kt new file mode 100644 index 0000000000..ad6388bdc9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/dal/SoftwareAppsFeedFilter.kt @@ -0,0 +1,56 @@ +/* + * 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.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent + +class SoftwareAppsFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = false + + override fun feed(): List { + val notes = + LocalCache.addressables.filterIntoSet(SoftwareApplicationEvent.KIND) { _, it -> + val ev = it.event + ev is SoftwareApplicationEvent && account.isAcceptable(it) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = + newItems.filterTo(HashSet()) { + val ev = it.event + ev is SoftwareApplicationEvent && account.isAcceptable(it) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssembler.kt new file mode 100644 index 0000000000..83322bf211 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssembler.kt @@ -0,0 +1,78 @@ +/* + * 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.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.CoroutineScope + +class SoftwareAppsQueryState( + val account: Account, + val scope: CoroutineScope, +) + +@Stable +class SoftwareAppsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val sub = SoftwareAppsSubAssembler(client, ::allKeys) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = sub.invalidateFilters() + + override fun destroy() = sub.destroy() +} + +class SoftwareAppsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: SoftwareAppsQueryState) = key.account.userProfile() + + override fun updateFilter( + key: SoftwareAppsQueryState, + since: SincePerRelayMap?, + ): List { + val relays = key.account.outboxRelays.flow.value + if (relays.isEmpty()) return emptyList() + + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), + limit = 200, + since = since?.get(relay)?.time, + ), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..3df95fec8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * 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.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun SoftwareAppsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + SoftwareAppsFilterAssemblerSubscription( + accountViewModel.dataSources().softwareApps, + accountViewModel, + ) +} + +@Composable +fun SoftwareAppsFilterAssemblerSubscription( + dataSource: SoftwareAppsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + SoftwareAppsQueryState(accountViewModel.account, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 09219adb59..9ea627a300 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -182,6 +182,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication +import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset +import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent @@ -225,6 +228,9 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -264,6 +270,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -725,6 +732,12 @@ private fun FullBleedNoteCompose( RenderGitIssueEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is AppDefinitionEvent) { RenderAppDefinition(baseNote, accountViewModel, nav) + } else if (noteEvent is SoftwareApplicationEvent) { + RenderSoftwareApplication(baseNote, accountViewModel, nav) + } else if (noteEvent is SoftwareAssetEvent) { + RenderSoftwareAsset(baseNote, accountViewModel, nav) + } else if (noteEvent is ReleaseArtifactSetEvent && noteEvent.isNip82SoftwareRelease()) { + RenderSoftwareRelease(baseNote, accountViewModel, nav) } else if (noteEvent is DraftWrapEvent) { RenderDraft(baseNote, 3, ReplyRenderType.FULL, backgroundColor, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 15b09c7920..827d73674a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -14,6 +14,10 @@ This post has more than %1$d hashtag This post has more than %1$d hashtags + + %1$d asset bundled + %1$d assets bundled + Event is loading or can\'t be found in your relay list 👀 Channel Image @@ -583,6 +587,11 @@ Choose which of the badges you\'ve received appear on your profile. You haven\'t received any badges yet. Pictures + Apps + Apps + Source: %1$s + v%1$s + Download Calendars Shorts Public Chats diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/SoftwareApplicationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/SoftwareApplicationEvent.kt index 3f92df0ab4..f327f49c20 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/SoftwareApplicationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/SoftwareApplicationEvent.kt @@ -25,7 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -63,6 +65,10 @@ class SoftwareApplicationEvent( fun license() = tags.license() + fun topics() = tags.mapNotNull(HashtagTag::parse) + + fun appLinks() = tags.mapNotNull(ATag::parse) + companion object { const val KIND = 32267 const val ALT_DESCRIPTION = "Software application" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/TagArrayBuilderExt.kt index b95b36776e..229b1374fa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/application/TagArrayBuilderExt.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.tags. import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.tags.UrlTag import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.shared.PlatformTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) @@ -49,3 +50,5 @@ fun TagArrayBuilder.platform(platform: String) = add(P fun TagArrayBuilder.platforms(platforms: List) = addAll(PlatformTag.assemble(platforms)) fun TagArrayBuilder.license(spdxId: String) = addUnique(LicenseTag.assemble(spdxId)) + +fun TagArrayBuilder.appLink(addressTag: String) = add(ATag.assemble(addressTag, null)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/Nip82ReleaseDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/Nip82ReleaseDetector.kt new file mode 100644 index 0000000000..a8932e8ec0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/Nip82ReleaseDetector.kt @@ -0,0 +1,45 @@ +/* + * 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.quartz.experimental.nip82SoftwareApps.release + +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.AppIdTag +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent + +/** + * NIP-82 reuses kind 30063 which was already taken by NIP-51's + * `ReleaseArtifactSetEvent`. Both are valid; they coexist on the wire. + * + * We disambiguate by inspecting the tag set: a NIP-82 Software Release MUST + * carry both `i` (app identifier) and `version` tags. NIP-51 release artifact + * sets do not. + */ +fun Event.isNip82SoftwareRelease() = + kind == SoftwareReleaseEvent.KIND && + tags.hasTagWithContent(AppIdTag.TAG_NAME) && + tags.hasTagWithContent(VersionTag.TAG_NAME) + +/** + * Reinterprets `this` as a [SoftwareReleaseEvent]. Use [isNip82SoftwareRelease] + * to gate the call. + */ +fun Event.asSoftwareRelease() = SoftwareReleaseEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/SoftwareApplicationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/SoftwareApplicationEventTest.kt new file mode 100644 index 0000000000..c92f07fb5a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/SoftwareApplicationEventTest.kt @@ -0,0 +1,228 @@ +/* + * 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.quartz.experimental.nip82SoftwareApps + +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.icon +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.images +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.license +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.platform +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.repository +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.summary +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.url +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.apkCertificateHash +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.platform +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.versionCode +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.experimental.nip82SoftwareApps.shared.Platform +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SoftwareApplicationEventTest { + @Test + fun buildApplicationEvent_setsRequiredAndOptionalTags() { + val tmpl = + SoftwareApplicationEvent.build( + appId = "com.example.app", + name = "Example", + description = "Demo app", + ) { + summary("A short summary") + icon("https://cdn.example.com/icon.png") + url("https://example.com") + repository("https://github.com/example/app") + license("MIT") + platform(Platform.ANDROID_ARM64_V8A) + hashtag("nostr") + } + + assertEquals(SoftwareApplicationEvent.KIND, tmpl.kind) + assertEquals("Demo app", tmpl.content) + + // Verify some key tags exist + val tagsByName = tmpl.tags.associate { it[0] to it.drop(1) } + assertEquals("com.example.app", tagsByName["d"]?.firstOrNull()) + assertEquals("Example", tagsByName["name"]?.firstOrNull()) + assertEquals("A short summary", tagsByName["summary"]?.firstOrNull()) + assertEquals("MIT", tagsByName["license"]?.firstOrNull()) + assertEquals(Platform.ANDROID_ARM64_V8A, tagsByName["f"]?.firstOrNull()) + assertEquals("https://github.com/example/app", tagsByName["repository"]?.firstOrNull()) + } + + @Test + fun buildAssetEvent_setsRequiredTags() { + val tmpl = + SoftwareAssetEvent.build( + appId = "com.example.app", + mimeType = "application/vnd.android.package-archive", + hash = "deadbeef".repeat(8), + version = "1.2.3", + assetUrl = "https://example.com/app.apk", + sizeInBytes = 1024, + ) { + versionCode(42L) + platform(Platform.ANDROID_ARM64_V8A) + apkCertificateHash("abc123".repeat(10).take(64)) + } + + assertEquals(SoftwareAssetEvent.KIND, tmpl.kind) + + val tagsByName = tmpl.tags.associate { it[0] to it.drop(1) } + assertEquals("com.example.app", tagsByName["i"]?.firstOrNull()) + assertEquals("1.2.3", tagsByName["version"]?.firstOrNull()) + assertEquals("42", tagsByName["version_code"]?.firstOrNull()) + assertEquals("application/vnd.android.package-archive", tagsByName["m"]?.firstOrNull()) + } + + @Test + fun isNip82SoftwareRelease_distinguishesFromNip51ReleaseArtifactSet() { + val nip82Release = + Event( + id = "0".repeat(64), + pubKey = "1".repeat(64), + createdAt = 0, + kind = SoftwareReleaseEvent.KIND, + tags = + arrayOf( + arrayOf("d", "com.example.app@1.0.0"), + arrayOf("i", "com.example.app"), + arrayOf("version", "1.0.0"), + arrayOf("c", "main"), + ), + content = "", + sig = "", + ) + assertTrue(nip82Release.isNip82SoftwareRelease()) + + // NIP-51 release artifact set: same kind, no `i`/`version` tags + val nip51Set = + Event( + id = "0".repeat(64), + pubKey = "1".repeat(64), + createdAt = 0, + kind = SoftwareReleaseEvent.KIND, + tags = arrayOf(arrayOf("d", "some-uuid"), arrayOf("title", "My Release")), + content = "", + sig = "", + ) + assertFalse(nip51Set.isNip82SoftwareRelease()) + } + + @Test + fun asSoftwareRelease_exposesParsedFields() { + val event = + Event( + id = "0".repeat(64), + pubKey = "1".repeat(64), + createdAt = 0, + kind = SoftwareReleaseEvent.KIND, + tags = + arrayOf( + arrayOf("d", "com.example.app@1.0.0"), + arrayOf("i", "com.example.app"), + arrayOf("version", "1.0.0"), + arrayOf("c", "main"), + ), + content = "Initial release", + sig = "", + ) + val release = event.asSoftwareRelease() + assertEquals("com.example.app", release.appId()) + assertEquals("1.0.0", release.version()) + assertEquals("main", release.channel()) + } + + @Test + fun parses_realWorldAmethystEvent() { + // Real NIP-82 event published for Amethyst — used as a regression fixture so renderer + // changes are caught against a known-good event seen on the wire. + val event = + SoftwareApplicationEvent( + id = "1c59e61478a39a963a6e513b8483b2d429ce1d9d98d1c7dfcd46b6a03dbfc6ea", + pubKey = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b", + createdAt = 1778966309L, + tags = + arrayOf( + arrayOf("d", "com.vitorpamplona.amethyst"), + arrayOf("name", "Amethyst"), + arrayOf("summary", "The all-in-one Nostr client"), + arrayOf("icon", "https://cdn.zapstore.dev/8064b9cb04395b8039390c51b13095b54f1958fcfe7a2515f6ff202a7567d049"), + arrayOf("image", "https://cdn.zapstore.dev/a1dfb3c5fada19c39c85049d6cd97fbf5813feca62b52839a269a2c459c726d2"), + arrayOf("image", "https://cdn.zapstore.dev/ff224b4887cba187238bbf10428523006374dea6692eb6f1dd7256aeff322a78"), + arrayOf("image", "https://cdn.zapstore.dev/9b088b467b2f217f0b3eee6f12afa907cd3fce54b63766ff93e03fb1f891e8b5"), + arrayOf("image", "https://cdn.zapstore.dev/d4580b70f4e741be2d95e6f51c608aac133db77868e8060c2d12dd6350168320"), + arrayOf("t", "social-network"), + arrayOf("t", "nostr"), + arrayOf("url", "https://amethyst.social/"), + arrayOf("repository", "https://github.com/vitorpamplona/amethyst"), + arrayOf("f", "android-arm64-v8a"), + arrayOf("license", "MIT"), + arrayOf("h", "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"), + ), + content = "A privacy-focused Nostr client for Android. Built-in TOR support, the most configurable relay system,\nencrypted messaging, zaps, marketplaces, live streams and complete data sovereignty.\n", + sig = "c209c6d97a7b94acc827755e615f729ff8c9bc6c9eacd9c92e35685b5a89ba95f24cbbc7f5696683bc19f813cd7443c60900acf13ba6b621834e804b1079d130", + ) + + assertEquals("com.vitorpamplona.amethyst", event.appId()) + assertEquals("Amethyst", event.name()) + assertEquals("The all-in-one Nostr client", event.summary()) + assertEquals("https://cdn.zapstore.dev/8064b9cb04395b8039390c51b13095b54f1958fcfe7a2515f6ff202a7567d049", event.icon()) + assertEquals(4, event.images().size) + assertEquals(listOf("social-network", "nostr"), event.topics()) + assertEquals("https://amethyst.social/", event.url()) + assertEquals("https://github.com/vitorpamplona/amethyst", event.repository()) + assertEquals(listOf(Platform.ANDROID_ARM64_V8A), event.platforms()) + assertEquals("MIT", event.license()) + } + + @Test + fun applicationParses_imagesAndPlatforms() { + val tmpl = + SoftwareApplicationEvent.build( + appId = "com.example.app", + name = "Example", + ) { + images(listOf("https://example.com/a.png", "https://example.com/b.png")) + platform(Platform.LINUX_X86_64) + platform(Platform.DARWIN_ARM64) + } + val event = + SoftwareApplicationEvent( + id = "0".repeat(64), + pubKey = "1".repeat(64), + createdAt = 0, + tags = tmpl.tags, + content = tmpl.content, + sig = "", + ) + assertEquals(2, event.images().size) + assertEquals(2, event.platforms().size) + assertNotNull(event.platforms().firstOrNull { it == Platform.LINUX_X86_64 }) + } +}